MikelWL commited on
Commit
a7fd580
·
1 Parent(s): 29dd5f0

Feat: persist sealed runs to SQLite

Browse files
.env.example CHANGED
@@ -28,5 +28,11 @@ LLM_APP_NAME=AI_Survey_Simulator
28
  FRONTEND_BACKEND_BASE_URL=http://localhost:8000
29
  FRONTEND_WEBSOCKET_URL=ws://localhost:8000/ws/conversation
30
 
 
 
 
 
 
 
31
  # Logging
32
  LOG_LEVEL=INFO
 
28
  FRONTEND_BACKEND_BASE_URL=http://localhost:8000
29
  FRONTEND_WEBSOCKET_URL=ws://localhost:8000/ws/conversation
30
 
31
+ # Persistence (SQLite)
32
+ # Local default:
33
+ DB_PATH=.localdata/converta.db
34
+ # Hugging Face Spaces recommended:
35
+ # DB_PATH=/data/converta/converta.db
36
+
37
  # Logging
38
  LOG_LEVEL=INFO
README.md CHANGED
@@ -45,6 +45,9 @@ LLM_APP_NAME=AI_Survey_Simulator
45
 
46
  Other environment values (ports, websocket URL, log level) are already set in `.env.example`.
47
 
 
 
 
48
  ---
49
 
50
  ## 2. Install Python Dependencies
 
45
 
46
  Other environment values (ports, websocket URL, log level) are already set in `.env.example`.
47
 
48
+ Persistence:
49
+ - `DB_PATH` controls where sealed runs are stored (SQLite). Local default: `.localdata/converta.db`; HF Spaces: `/data/converta/converta.db`.
50
+
51
  ---
52
 
53
  ## 2. Install Python Dependencies
backend/api/analysis_routes.py CHANGED
@@ -2,6 +2,7 @@
2
 
3
  from __future__ import annotations
4
 
 
5
  import io
6
  import re
7
  from datetime import datetime
@@ -11,9 +12,12 @@ from fastapi import APIRouter, File, Form, HTTPException, UploadFile
11
  from pydantic import BaseModel, Field
12
 
13
  from .conversation_service import run_resource_agent_analysis
 
14
  from config.settings import get_settings
 
15
 
16
  router = APIRouter(prefix="", tags=["analysis"])
 
17
 
18
 
19
  class ExportMessage(BaseModel):
@@ -30,6 +34,8 @@ class AnalyzeTextRequest(BaseModel):
30
 
31
 
32
  class AnalyzeTextResponse(BaseModel):
 
 
33
  conversation_id: str
34
  messages: List[ExportMessage]
35
  resources: Dict[str, Any]
@@ -136,7 +142,48 @@ async def _analyze_from_text(*, text: str, conversation_id: str, source_name: Op
136
  settings=settings,
137
  )
138
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  return AnalyzeTextResponse(
 
 
140
  conversation_id=conversation_id,
141
  messages=ui_messages,
142
  resources=resources,
@@ -208,4 +255,3 @@ async def analyze_file(
208
  conversation_id=cid,
209
  source_name=inferred_name,
210
  )
211
-
 
2
 
3
  from __future__ import annotations
4
 
5
+ import logging
6
  import io
7
  import re
8
  from datetime import datetime
 
12
  from pydantic import BaseModel, Field
13
 
14
  from .conversation_service import run_resource_agent_analysis
15
+ from .storage_service import get_run_store
16
  from config.settings import get_settings
17
+ from backend.storage import RunRecord
18
 
19
  router = APIRouter(prefix="", tags=["analysis"])
20
+ logger = logging.getLogger(__name__)
21
 
22
 
23
  class ExportMessage(BaseModel):
 
34
 
35
 
36
  class AnalyzeTextResponse(BaseModel):
37
+ run_id: Optional[str] = None
38
+ persisted: bool = False
39
  conversation_id: str
40
  messages: List[ExportMessage]
41
  resources: Dict[str, Any]
 
142
  settings=settings,
143
  )
144
 
145
+ persisted = False
146
+ run_id = None
147
+ try:
148
+ store = get_run_store()
149
+ run_id = conversation_id
150
+ config_snapshot: Dict[str, Any] = {
151
+ "llm": {
152
+ "backend": settings.llm.backend,
153
+ "host": settings.llm.host,
154
+ "model": settings.llm.model,
155
+ "timeout": settings.llm.timeout,
156
+ "max_retries": settings.llm.max_retries,
157
+ "retry_delay": settings.llm.retry_delay,
158
+ },
159
+ "text_analysis": {
160
+ "source_name": source_name,
161
+ },
162
+ }
163
+ record = RunRecord(
164
+ run_id=run_id,
165
+ mode="text_analysis",
166
+ status="completed",
167
+ created_at=exported_at,
168
+ ended_at=exported_at,
169
+ sealed_at=exported_at,
170
+ title=None,
171
+ input_summary=source_name,
172
+ config=config_snapshot,
173
+ messages=transcript,
174
+ analyses={"resource_agent_v2": resources},
175
+ persona_snapshots={},
176
+ )
177
+ await store.save_sealed_run(record)
178
+ persisted = True
179
+ except Exception as e:
180
+ logger.error(f"Failed to persist sealed text analysis {conversation_id}: {e}")
181
+ persisted = False
182
+ run_id = None
183
+
184
  return AnalyzeTextResponse(
185
+ run_id=run_id,
186
+ persisted=persisted,
187
  conversation_id=conversation_id,
188
  messages=ui_messages,
189
  resources=resources,
 
255
  conversation_id=cid,
256
  source_name=inferred_name,
257
  )
 
backend/api/conversation_service.py CHANGED
@@ -40,6 +40,8 @@ from backend.core.conversation_manager import ConversationManager # noqa: E402
40
  from backend.core.llm_client import create_llm_client # noqa: E402
41
  from backend.core.persona_system import PersonaSystem # noqa: E402
42
  from .conversation_ws import ConnectionManager # noqa: E402
 
 
43
 
44
  # Setup logging
45
  logger = logging.getLogger(__name__)
@@ -821,6 +823,7 @@ class ConversationService:
821
  if not conv_info:
822
  return
823
  try:
 
824
  parsed = await run_resource_agent_analysis(
825
  transcript=transcript,
826
  llm_backend=conv_info.llm_backend,
@@ -828,9 +831,66 @@ class ConversationService:
828
  model=conv_info.model,
829
  settings=self.settings,
830
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
831
  await self.websocket_manager.send_to_conversation(conversation_id, {
832
  "type": "resource_agent_result",
833
  "conversation_id": conversation_id,
 
 
834
  "data": parsed,
835
  "timestamp": datetime.now().isoformat(),
836
  })
 
40
  from backend.core.llm_client import create_llm_client # noqa: E402
41
  from backend.core.persona_system import PersonaSystem # noqa: E402
42
  from .conversation_ws import ConnectionManager # noqa: E402
43
+ from .storage_service import get_run_store # noqa: E402
44
+ from backend.storage import RunRecord # noqa: E402
45
 
46
  # Setup logging
47
  logger = logging.getLogger(__name__)
 
823
  if not conv_info:
824
  return
825
  try:
826
+ seal_timestamp = datetime.now().isoformat()
827
  parsed = await run_resource_agent_analysis(
828
  transcript=transcript,
829
  llm_backend=conv_info.llm_backend,
 
831
  model=conv_info.model,
832
  settings=self.settings,
833
  )
834
+
835
+ persisted = False
836
+ run_id = None
837
+ try:
838
+ store = get_run_store()
839
+ mode = "human_to_ai" if conversation_id in self.active_human_chats else "ai_to_ai"
840
+
841
+ persona_snapshots: Dict[str, Dict[str, Any]] = {}
842
+ try:
843
+ surveyor_persona = self.persona_system.get_persona(conv_info.surveyor_persona_id) or {}
844
+ patient_persona = self.persona_system.get_persona(conv_info.patient_persona_id) or {}
845
+ persona_snapshots = {
846
+ "surveyor": {"persona_id": conv_info.surveyor_persona_id, "persona_version_id": None, "snapshot": surveyor_persona},
847
+ "patient": {"persona_id": conv_info.patient_persona_id, "persona_version_id": None, "snapshot": patient_persona},
848
+ }
849
+ except Exception:
850
+ persona_snapshots = {}
851
+
852
+ config_snapshot: Dict[str, Any] = {
853
+ "llm": {
854
+ "backend": conv_info.llm_backend,
855
+ "host": conv_info.host,
856
+ "model": conv_info.model,
857
+ "timeout": self.settings.llm.timeout,
858
+ "max_retries": self.settings.llm.max_retries,
859
+ "retry_delay": self.settings.llm.retry_delay,
860
+ },
861
+ "personas": {
862
+ "surveyor_persona_id": conv_info.surveyor_persona_id,
863
+ "patient_persona_id": conv_info.patient_persona_id,
864
+ "surveyor_prompt_addition": getattr(conv_info, "surveyor_prompt_addition", None),
865
+ "patient_prompt_addition": getattr(conv_info, "patient_prompt_addition", None),
866
+ },
867
+ }
868
+
869
+ run_id = conversation_id
870
+ record = RunRecord(
871
+ run_id=run_id,
872
+ mode=mode,
873
+ status="completed",
874
+ created_at=getattr(conv_info, "created_at").isoformat(),
875
+ ended_at=seal_timestamp,
876
+ sealed_at=seal_timestamp,
877
+ title=None,
878
+ input_summary=None,
879
+ config=config_snapshot,
880
+ messages=transcript,
881
+ analyses={"resource_agent_v2": parsed},
882
+ persona_snapshots=persona_snapshots,
883
+ )
884
+ await store.save_sealed_run(record)
885
+ persisted = True
886
+ except Exception as e:
887
+ logger.error(f"Failed to persist sealed run {conversation_id}: {e}")
888
+
889
  await self.websocket_manager.send_to_conversation(conversation_id, {
890
  "type": "resource_agent_result",
891
  "conversation_id": conversation_id,
892
+ "run_id": run_id if persisted else None,
893
+ "persisted": persisted,
894
  "data": parsed,
895
  "timestamp": datetime.now().isoformat(),
896
  })
backend/api/export_routes.py CHANGED
@@ -28,6 +28,11 @@ class ExportRequest(BaseModel):
28
  resources: Dict[str, Any] = Field(default_factory=dict, description="Resource agent output + evidence_catalog")
29
 
30
 
 
 
 
 
 
31
  def _safe_join(values: Any, sep: str = "; ") -> str:
32
  if not values:
33
  return ""
@@ -48,8 +53,7 @@ def _extract_evidence_ids(evidence: Any) -> List[str]:
48
  return evidence_ids
49
 
50
 
51
- @router.post("/export/json")
52
- async def export_json(payload: ExportRequest) -> Response:
53
  exported_at = payload.exported_at or datetime.now().isoformat()
54
  export_obj = {
55
  "conversation_id": payload.conversation_id,
@@ -57,19 +61,12 @@ async def export_json(payload: ExportRequest) -> Response:
57
  "messages": [m.model_dump() for m in payload.messages],
58
  "resources": payload.resources,
59
  }
60
- data = json.dumps(export_obj, ensure_ascii=False, indent=2).encode("utf-8")
61
- filename = f"converta_{payload.conversation_id}_{exported_at.replace(':', '-')}.json"
62
- headers = {"Content-Disposition": f'attachment; filename="{filename}"'}
63
- return Response(content=data, media_type="application/json", headers=headers)
64
 
65
 
66
- @router.post("/export/xlsx")
67
- async def export_xlsx(payload: ExportRequest) -> Response:
68
- try:
69
- from openpyxl import Workbook
70
- from openpyxl.styles import Alignment, Font
71
- except Exception as e:
72
- raise HTTPException(status_code=500, detail=f"openpyxl not available: {e}")
73
 
74
  exported_at = payload.exported_at or datetime.now().isoformat()
75
  wb = Workbook()
@@ -108,6 +105,7 @@ async def export_xlsx(payload: ExportRequest) -> Response:
108
  evidence_catalog = payload.resources.get("evidence_catalog") or {}
109
  ws_ev = add_sheet("EvidenceCatalog", ["evidence_id", "message_index", "sentence_index", "sentence_text"])
110
  if isinstance(evidence_catalog, dict):
 
111
  def sort_key(item):
112
  _, val = item
113
  if not isinstance(val, dict):
@@ -119,12 +117,14 @@ async def export_xlsx(payload: ExportRequest) -> Response:
119
  for evidence_id, entry in sorted(evidence_catalog.items(), key=sort_key):
120
  if not isinstance(entry, dict):
121
  continue
122
- ws_ev.append([
123
- str(evidence_id),
124
- entry.get("message_index", ""),
125
- entry.get("sentence_index", ""),
126
- entry.get("text", "") or "",
127
- ])
 
 
128
 
129
  ws_hs = add_sheet("HealthSituations", ["index", "code", "summary", "confidence", "evidence_ids"])
130
  health_situations = payload.resources.get("health_situations") or []
@@ -133,13 +133,15 @@ async def export_xlsx(payload: ExportRequest) -> Response:
133
  if not isinstance(item, dict):
134
  continue
135
  evidence_ids = _extract_evidence_ids(item.get("evidence"))
136
- ws_hs.append([
137
- idx,
138
- item.get("code", "") or "",
139
- item.get("summary", "") or "",
140
- item.get("confidence", ""),
141
- _safe_join(evidence_ids),
142
- ])
 
 
143
 
144
  ws_care = add_sheet("CareExperience", ["tone", "summary", "confidence", "reasons", "evidence_ids"])
145
  care = payload.resources.get("care_experience") or {}
@@ -149,13 +151,15 @@ async def export_xlsx(payload: ExportRequest) -> Response:
149
  if not isinstance(box, dict):
150
  continue
151
  evidence_ids = _extract_evidence_ids(box.get("evidence"))
152
- ws_care.append([
153
- tone,
154
- box.get("summary", "") or "",
155
- box.get("confidence", ""),
156
- _safe_join(box.get("reasons") or [], sep=" | "),
157
- _safe_join(evidence_ids),
158
- ])
 
 
159
 
160
  ws_td = add_sheet("TopDownCodes", ["category", "index", "code", "summary", "confidence", "evidence_ids"])
161
  top_down = payload.resources.get("top_down_codes") or {}
@@ -167,14 +171,16 @@ async def export_xlsx(payload: ExportRequest) -> Response:
167
  if not isinstance(item, dict):
168
  continue
169
  evidence_ids = _extract_evidence_ids(item.get("evidence"))
170
- ws_td.append([
171
- str(category),
172
- idx,
173
- item.get("code", "") or "",
174
- item.get("summary", "") or "",
175
- item.get("confidence", ""),
176
- _safe_join(evidence_ids),
177
- ])
 
 
178
 
179
  for ws in wb.worksheets:
180
  for row in ws.iter_rows(min_row=2):
@@ -183,13 +189,30 @@ async def export_xlsx(payload: ExportRequest) -> Response:
183
 
184
  buf = io.BytesIO()
185
  wb.save(buf)
186
- data = buf.getvalue()
187
 
188
- filename = f"converta_{payload.conversation_id}_{exported_at.replace(':', '-')}.xlsx"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  headers = {"Content-Disposition": f'attachment; filename="{filename}"'}
190
  return Response(
191
  content=data,
192
  media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
193
  headers=headers,
194
  )
195
-
 
28
  resources: Dict[str, Any] = Field(default_factory=dict, description="Resource agent output + evidence_catalog")
29
 
30
 
31
+ def _filename(conversation_id: str, exported_at: str, ext: str) -> str:
32
+ safe_ts = exported_at.replace(":", "-")
33
+ return f"converta_{conversation_id}_{safe_ts}.{ext.lstrip('.')}"
34
+
35
+
36
  def _safe_join(values: Any, sep: str = "; ") -> str:
37
  if not values:
38
  return ""
 
53
  return evidence_ids
54
 
55
 
56
+ def _export_json_bytes(payload: ExportRequest) -> bytes:
 
57
  exported_at = payload.exported_at or datetime.now().isoformat()
58
  export_obj = {
59
  "conversation_id": payload.conversation_id,
 
61
  "messages": [m.model_dump() for m in payload.messages],
62
  "resources": payload.resources,
63
  }
64
+ return json.dumps(export_obj, ensure_ascii=False, indent=2).encode("utf-8")
 
 
 
65
 
66
 
67
+ def _export_xlsx_bytes(payload: ExportRequest) -> bytes:
68
+ from openpyxl import Workbook
69
+ from openpyxl.styles import Alignment, Font
 
 
 
 
70
 
71
  exported_at = payload.exported_at or datetime.now().isoformat()
72
  wb = Workbook()
 
105
  evidence_catalog = payload.resources.get("evidence_catalog") or {}
106
  ws_ev = add_sheet("EvidenceCatalog", ["evidence_id", "message_index", "sentence_index", "sentence_text"])
107
  if isinstance(evidence_catalog, dict):
108
+
109
  def sort_key(item):
110
  _, val = item
111
  if not isinstance(val, dict):
 
117
  for evidence_id, entry in sorted(evidence_catalog.items(), key=sort_key):
118
  if not isinstance(entry, dict):
119
  continue
120
+ ws_ev.append(
121
+ [
122
+ str(evidence_id),
123
+ entry.get("message_index", ""),
124
+ entry.get("sentence_index", ""),
125
+ entry.get("text", "") or "",
126
+ ]
127
+ )
128
 
129
  ws_hs = add_sheet("HealthSituations", ["index", "code", "summary", "confidence", "evidence_ids"])
130
  health_situations = payload.resources.get("health_situations") or []
 
133
  if not isinstance(item, dict):
134
  continue
135
  evidence_ids = _extract_evidence_ids(item.get("evidence"))
136
+ ws_hs.append(
137
+ [
138
+ idx,
139
+ item.get("code", "") or "",
140
+ item.get("summary", "") or "",
141
+ item.get("confidence", ""),
142
+ _safe_join(evidence_ids),
143
+ ]
144
+ )
145
 
146
  ws_care = add_sheet("CareExperience", ["tone", "summary", "confidence", "reasons", "evidence_ids"])
147
  care = payload.resources.get("care_experience") or {}
 
151
  if not isinstance(box, dict):
152
  continue
153
  evidence_ids = _extract_evidence_ids(box.get("evidence"))
154
+ ws_care.append(
155
+ [
156
+ tone,
157
+ box.get("summary", "") or "",
158
+ box.get("confidence", ""),
159
+ _safe_join(box.get("reasons") or [], sep=" | "),
160
+ _safe_join(evidence_ids),
161
+ ]
162
+ )
163
 
164
  ws_td = add_sheet("TopDownCodes", ["category", "index", "code", "summary", "confidence", "evidence_ids"])
165
  top_down = payload.resources.get("top_down_codes") or {}
 
171
  if not isinstance(item, dict):
172
  continue
173
  evidence_ids = _extract_evidence_ids(item.get("evidence"))
174
+ ws_td.append(
175
+ [
176
+ str(category),
177
+ idx,
178
+ item.get("code", "") or "",
179
+ item.get("summary", "") or "",
180
+ item.get("confidence", ""),
181
+ _safe_join(evidence_ids),
182
+ ]
183
+ )
184
 
185
  for ws in wb.worksheets:
186
  for row in ws.iter_rows(min_row=2):
 
189
 
190
  buf = io.BytesIO()
191
  wb.save(buf)
192
+ return buf.getvalue()
193
 
194
+
195
+ @router.post("/export/json")
196
+ async def export_json(payload: ExportRequest) -> Response:
197
+ exported_at = payload.exported_at or datetime.now().isoformat()
198
+ data = _export_json_bytes(payload)
199
+ filename = _filename(payload.conversation_id, exported_at, "json")
200
+ headers = {"Content-Disposition": f'attachment; filename="{filename}"'}
201
+ return Response(content=data, media_type="application/json", headers=headers)
202
+
203
+
204
+ @router.post("/export/xlsx")
205
+ async def export_xlsx(payload: ExportRequest) -> Response:
206
+ try:
207
+ data = _export_xlsx_bytes(payload)
208
+ except Exception as e:
209
+ raise HTTPException(status_code=500, detail=f"openpyxl not available: {e}")
210
+
211
+ exported_at = payload.exported_at or datetime.now().isoformat()
212
+ filename = _filename(payload.conversation_id, exported_at, "xlsx")
213
  headers = {"Content-Disposition": f'attachment; filename="{filename}"'}
214
  return Response(
215
  content=data,
216
  media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
217
  headers=headers,
218
  )
 
backend/api/main.py CHANGED
@@ -32,7 +32,10 @@ from .conversation_routes import router as conversation_router # noqa: E402
32
  from .persona_routes import router as persona_router # noqa: E402
33
  from .export_routes import router as export_router # noqa: E402
34
  from .analysis_routes import router as analysis_router # noqa: E402
 
 
35
  from .conversation_service import initialize_conversation_service # noqa: E402
 
36
  from backend.core.auth import COOKIE_NAME, INTERNAL_HEADER, get_app_password, verify_session_token # noqa: E402
37
 
38
  # Load application settings
@@ -67,6 +70,8 @@ app.include_router(conversation_router)
67
  app.include_router(persona_router)
68
  app.include_router(export_router)
69
  app.include_router(analysis_router)
 
 
70
 
71
  @app.middleware("http")
72
  async def auth_middleware(request: Request, call_next):
@@ -102,6 +107,7 @@ async def startup_event():
102
 
103
  # Initialize conversation service with WebSocket manager and settings
104
  initialize_conversation_service(manager, settings)
 
105
 
106
  logger.info("API startup complete")
107
 
 
32
  from .persona_routes import router as persona_router # noqa: E402
33
  from .export_routes import router as export_router # noqa: E402
34
  from .analysis_routes import router as analysis_router # noqa: E402
35
+ from .run_routes import router as run_router # noqa: E402
36
+ from .run_export_routes import router as run_export_router # noqa: E402
37
  from .conversation_service import initialize_conversation_service # noqa: E402
38
+ from .storage_service import initialize_run_store # noqa: E402
39
  from backend.core.auth import COOKIE_NAME, INTERNAL_HEADER, get_app_password, verify_session_token # noqa: E402
40
 
41
  # Load application settings
 
70
  app.include_router(persona_router)
71
  app.include_router(export_router)
72
  app.include_router(analysis_router)
73
+ app.include_router(run_router)
74
+ app.include_router(run_export_router)
75
 
76
  @app.middleware("http")
77
  async def auth_middleware(request: Request, call_next):
 
107
 
108
  # Initialize conversation service with WebSocket manager and settings
109
  initialize_conversation_service(manager, settings)
110
+ await initialize_run_store(settings)
111
 
112
  logger.info("API startup complete")
113
 
backend/api/run_export_routes.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Export sealed runs using server-canonical persisted data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Any, Dict, List
7
+
8
+ from fastapi import APIRouter, HTTPException
9
+ from fastapi.responses import Response
10
+
11
+ from .export_routes import ExportMessage, ExportRequest, _export_json_bytes, _export_xlsx_bytes, _filename
12
+ from .storage_service import get_run_store
13
+
14
+ router = APIRouter(prefix="", tags=["runs", "export"])
15
+
16
+
17
+ def _payload_from_run(*, run_id: str, messages: List[Dict[str, Any]], resources: Dict[str, Any]) -> ExportRequest:
18
+ exported_at = datetime.now().isoformat()
19
+ export_messages = [
20
+ ExportMessage(
21
+ role=str(m.get("role") or ""),
22
+ persona=m.get("persona"),
23
+ time=m.get("timestamp"),
24
+ text=str(m.get("content") or ""),
25
+ )
26
+ for m in (messages or [])
27
+ ]
28
+ return ExportRequest(
29
+ conversation_id=run_id,
30
+ exported_at=exported_at,
31
+ messages=export_messages,
32
+ resources=resources or {},
33
+ )
34
+
35
+
36
+ @router.get("/runs/{run_id}/export/json")
37
+ async def export_run_json(run_id: str) -> Response:
38
+ store = get_run_store()
39
+ record = await store.get_run(run_id)
40
+ if record is None:
41
+ raise HTTPException(status_code=404, detail="Run not found")
42
+
43
+ resources = record.analyses.get("resource_agent_v2") or {}
44
+ payload = _payload_from_run(run_id=record.run_id, messages=record.messages, resources=resources)
45
+ data = _export_json_bytes(payload)
46
+ filename = _filename(payload.conversation_id, payload.exported_at or datetime.now().isoformat(), "json")
47
+ headers = {"Content-Disposition": f'attachment; filename="{filename}"'}
48
+ return Response(content=data, media_type="application/json", headers=headers)
49
+
50
+
51
+ @router.get("/runs/{run_id}/export/xlsx")
52
+ async def export_run_xlsx(run_id: str) -> Response:
53
+ store = get_run_store()
54
+ record = await store.get_run(run_id)
55
+ if record is None:
56
+ raise HTTPException(status_code=404, detail="Run not found")
57
+
58
+ resources = record.analyses.get("resource_agent_v2") or {}
59
+ payload = _payload_from_run(run_id=record.run_id, messages=record.messages, resources=resources)
60
+ try:
61
+ data = _export_xlsx_bytes(payload)
62
+ except Exception as e:
63
+ raise HTTPException(status_code=500, detail=f"openpyxl not available: {e}")
64
+
65
+ filename = _filename(payload.conversation_id, payload.exported_at or datetime.now().isoformat(), "xlsx")
66
+ headers = {"Content-Disposition": f'attachment; filename="{filename}"'}
67
+ return Response(
68
+ content=data,
69
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
70
+ headers=headers,
71
+ )
72
+
backend/api/run_routes.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run history endpoints (sealed runs only)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ from fastapi import APIRouter, HTTPException, Query
8
+ from pydantic import BaseModel
9
+
10
+ from .storage_service import get_run_store
11
+
12
+ router = APIRouter(prefix="", tags=["runs"])
13
+
14
+
15
+ class RunSummaryResponse(BaseModel):
16
+ run_id: str
17
+ mode: str
18
+ status: str
19
+ created_at: str
20
+ ended_at: str
21
+ title: Optional[str] = None
22
+ input_summary: Optional[str] = None
23
+
24
+
25
+ class RunMessageResponse(BaseModel):
26
+ role: str
27
+ persona: Optional[str] = None
28
+ time: Optional[str] = None
29
+ text: str
30
+
31
+
32
+ class RunRecordResponse(BaseModel):
33
+ run_id: str
34
+ conversation_id: str
35
+ mode: str
36
+ status: str
37
+ created_at: str
38
+ ended_at: str
39
+ sealed_at: str
40
+ title: Optional[str] = None
41
+ input_summary: Optional[str] = None
42
+ config: Dict[str, Any]
43
+ messages: List[RunMessageResponse]
44
+ resources: Dict[str, Any]
45
+
46
+
47
+ @router.get("/runs", response_model=List[RunSummaryResponse])
48
+ async def list_runs(
49
+ mode: Optional[str] = Query(default=None),
50
+ limit: int = Query(default=50, ge=1, le=200),
51
+ offset: int = Query(default=0, ge=0),
52
+ ) -> List[RunSummaryResponse]:
53
+ store = get_run_store()
54
+ rows = await store.list_runs(mode=mode, limit=limit, offset=offset)
55
+ return [RunSummaryResponse(**row.__dict__) for row in rows]
56
+
57
+
58
+ @router.get("/runs/{run_id}", response_model=RunRecordResponse)
59
+ async def get_run(run_id: str) -> RunRecordResponse:
60
+ store = get_run_store()
61
+ record = await store.get_run(run_id)
62
+ if record is None:
63
+ raise HTTPException(status_code=404, detail="Run not found")
64
+
65
+ resources = record.analyses.get("resource_agent_v2") or {}
66
+ messages = [
67
+ RunMessageResponse(
68
+ role=str(m.get("role") or ""),
69
+ persona=m.get("persona"),
70
+ time=m.get("timestamp"),
71
+ text=str(m.get("content") or ""),
72
+ )
73
+ for m in (record.messages or [])
74
+ ]
75
+ return RunRecordResponse(
76
+ run_id=record.run_id,
77
+ conversation_id=record.run_id,
78
+ mode=record.mode,
79
+ status=record.status,
80
+ created_at=record.created_at,
81
+ ended_at=record.ended_at,
82
+ sealed_at=record.sealed_at,
83
+ title=record.title,
84
+ input_summary=record.input_summary,
85
+ config=record.config,
86
+ messages=messages,
87
+ resources=resources,
88
+ )
89
+
backend/api/storage_service.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Optional
5
+
6
+ from config.settings import AppSettings, get_settings
7
+ from backend.storage import SQLiteRunStore
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ run_store: Optional[SQLiteRunStore] = None
12
+
13
+
14
+ def get_run_store() -> SQLiteRunStore:
15
+ if run_store is None:
16
+ raise RuntimeError("RunStore not initialized")
17
+ return run_store
18
+
19
+
20
+ async def initialize_run_store(settings: Optional[AppSettings] = None) -> None:
21
+ global run_store
22
+ resolved = settings or get_settings()
23
+ store = SQLiteRunStore(resolved.db.path)
24
+ await store.init()
25
+ run_store = store
26
+ logger.info("RunStore initialized")
27
+
backend/storage/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from .models import RunRecord, RunSummary
2
+ from .sqlite_run_store import SQLiteRunStore
3
+
4
+ __all__ = ["RunRecord", "RunSummary", "SQLiteRunStore"]
5
+
backend/storage/models.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Dict, List, Optional
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class RunSummary:
9
+ run_id: str
10
+ mode: str
11
+ status: str
12
+ created_at: str
13
+ ended_at: str
14
+ title: Optional[str] = None
15
+ input_summary: Optional[str] = None
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class RunRecord:
20
+ run_id: str
21
+ mode: str
22
+ status: str
23
+ created_at: str
24
+ ended_at: str
25
+ sealed_at: str
26
+ config: Dict[str, Any]
27
+ messages: List[Dict[str, Any]]
28
+ analyses: Dict[str, Dict[str, Any]]
29
+ persona_snapshots: Dict[str, Dict[str, Any]]
30
+ title: Optional[str] = None
31
+ input_summary: Optional[str] = None
32
+
backend/storage/sqlite_run_store.py ADDED
@@ -0,0 +1,310 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ import aiosqlite
8
+
9
+ from .models import RunRecord, RunSummary
10
+
11
+
12
+ class SQLiteRunStore:
13
+ def __init__(self, db_path: str):
14
+ self.db_path = str(db_path)
15
+
16
+ async def init(self) -> None:
17
+ Path(self.db_path).expanduser().resolve().parent.mkdir(parents=True, exist_ok=True)
18
+ async with aiosqlite.connect(self.db_path) as db:
19
+ await db.execute("PRAGMA foreign_keys=ON;")
20
+ await db.execute(
21
+ """
22
+ CREATE TABLE IF NOT EXISTS runs (
23
+ run_id TEXT PRIMARY KEY,
24
+ mode TEXT NOT NULL,
25
+ status TEXT NOT NULL,
26
+ created_at TEXT NOT NULL,
27
+ ended_at TEXT NOT NULL,
28
+ title TEXT,
29
+ input_summary TEXT,
30
+ config_json TEXT NOT NULL,
31
+ sealed_at TEXT NOT NULL
32
+ );
33
+ """
34
+ )
35
+ await db.execute(
36
+ "CREATE INDEX IF NOT EXISTS runs_mode_created_at ON runs(mode, created_at DESC);"
37
+ )
38
+ await db.execute(
39
+ "CREATE INDEX IF NOT EXISTS runs_created_at ON runs(created_at DESC);"
40
+ )
41
+ await db.execute(
42
+ """
43
+ CREATE TABLE IF NOT EXISTS run_messages (
44
+ run_id TEXT NOT NULL,
45
+ message_index INTEGER NOT NULL,
46
+ role TEXT NOT NULL,
47
+ persona_label TEXT,
48
+ content TEXT NOT NULL,
49
+ timestamp TEXT,
50
+ PRIMARY KEY (run_id, message_index),
51
+ FOREIGN KEY (run_id) REFERENCES runs(run_id) ON DELETE CASCADE
52
+ );
53
+ """
54
+ )
55
+ await db.execute(
56
+ """
57
+ CREATE TABLE IF NOT EXISTS run_analyses (
58
+ run_id TEXT NOT NULL,
59
+ analysis_key TEXT NOT NULL,
60
+ schema_version TEXT,
61
+ prompt_version TEXT,
62
+ result_json TEXT NOT NULL,
63
+ PRIMARY KEY (run_id, analysis_key),
64
+ FOREIGN KEY (run_id) REFERENCES runs(run_id) ON DELETE CASCADE
65
+ );
66
+ """
67
+ )
68
+ await db.execute(
69
+ """
70
+ CREATE TABLE IF NOT EXISTS run_persona_snapshots (
71
+ run_id TEXT NOT NULL,
72
+ role TEXT NOT NULL,
73
+ persona_id TEXT,
74
+ persona_version_id TEXT,
75
+ snapshot_json TEXT NOT NULL,
76
+ PRIMARY KEY (run_id, role),
77
+ FOREIGN KEY (run_id) REFERENCES runs(run_id) ON DELETE CASCADE
78
+ );
79
+ """
80
+ )
81
+ await db.commit()
82
+
83
+ async def save_sealed_run(self, record: RunRecord) -> None:
84
+ Path(self.db_path).expanduser().resolve().parent.mkdir(parents=True, exist_ok=True)
85
+ async with aiosqlite.connect(self.db_path) as db:
86
+ await db.execute("PRAGMA foreign_keys=ON;")
87
+ await db.execute("BEGIN;")
88
+ try:
89
+ await db.execute(
90
+ """
91
+ INSERT INTO runs (
92
+ run_id, mode, status, created_at, ended_at,
93
+ title, input_summary, config_json, sealed_at
94
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);
95
+ """,
96
+ (
97
+ record.run_id,
98
+ record.mode,
99
+ record.status,
100
+ record.created_at,
101
+ record.ended_at,
102
+ record.title,
103
+ record.input_summary,
104
+ json.dumps(record.config, ensure_ascii=False),
105
+ record.sealed_at,
106
+ ),
107
+ )
108
+
109
+ if record.messages:
110
+ await db.executemany(
111
+ """
112
+ INSERT INTO run_messages (
113
+ run_id, message_index, role, persona_label, content, timestamp
114
+ ) VALUES (?, ?, ?, ?, ?, ?);
115
+ """,
116
+ [
117
+ (
118
+ record.run_id,
119
+ int(msg.get("index")),
120
+ str(msg.get("role") or ""),
121
+ msg.get("persona"),
122
+ str(msg.get("content") or ""),
123
+ msg.get("timestamp"),
124
+ )
125
+ for msg in record.messages
126
+ if isinstance(msg.get("index"), int)
127
+ ],
128
+ )
129
+
130
+ if record.analyses:
131
+ await db.executemany(
132
+ """
133
+ INSERT INTO run_analyses (
134
+ run_id, analysis_key, schema_version, prompt_version, result_json
135
+ ) VALUES (?, ?, ?, ?, ?);
136
+ """,
137
+ [
138
+ (
139
+ record.run_id,
140
+ key,
141
+ (val or {}).get("schema_version"),
142
+ (val or {}).get("analysis_prompt_version"),
143
+ json.dumps(val or {}, ensure_ascii=False),
144
+ )
145
+ for key, val in record.analyses.items()
146
+ ],
147
+ )
148
+
149
+ if record.persona_snapshots:
150
+ await db.executemany(
151
+ """
152
+ INSERT INTO run_persona_snapshots (
153
+ run_id, role, persona_id, persona_version_id, snapshot_json
154
+ ) VALUES (?, ?, ?, ?, ?);
155
+ """,
156
+ [
157
+ (
158
+ record.run_id,
159
+ role,
160
+ (snap or {}).get("persona_id"),
161
+ (snap or {}).get("persona_version_id"),
162
+ json.dumps((snap or {}).get("snapshot") or {}, ensure_ascii=False),
163
+ )
164
+ for role, snap in record.persona_snapshots.items()
165
+ ],
166
+ )
167
+
168
+ await db.commit()
169
+ except Exception:
170
+ await db.execute("ROLLBACK;")
171
+ raise
172
+
173
+ async def list_runs(
174
+ self,
175
+ *,
176
+ mode: Optional[str] = None,
177
+ limit: int = 50,
178
+ offset: int = 0,
179
+ ) -> List[RunSummary]:
180
+ limit = max(1, min(int(limit), 200))
181
+ offset = max(0, int(offset))
182
+
183
+ async with aiosqlite.connect(self.db_path) as db:
184
+ await db.execute("PRAGMA foreign_keys=ON;")
185
+ if mode:
186
+ rows = await db.execute_fetchall(
187
+ """
188
+ SELECT run_id, mode, status, created_at, ended_at, title, input_summary
189
+ FROM runs
190
+ WHERE mode = ?
191
+ ORDER BY created_at DESC
192
+ LIMIT ? OFFSET ?;
193
+ """,
194
+ (mode, limit, offset),
195
+ )
196
+ else:
197
+ rows = await db.execute_fetchall(
198
+ """
199
+ SELECT run_id, mode, status, created_at, ended_at, title, input_summary
200
+ FROM runs
201
+ ORDER BY created_at DESC
202
+ LIMIT ? OFFSET ?;
203
+ """,
204
+ (limit, offset),
205
+ )
206
+
207
+ return [
208
+ RunSummary(
209
+ run_id=row[0],
210
+ mode=row[1],
211
+ status=row[2],
212
+ created_at=row[3],
213
+ ended_at=row[4],
214
+ title=row[5],
215
+ input_summary=row[6],
216
+ )
217
+ for row in rows
218
+ ]
219
+
220
+ async def get_run(self, run_id: str) -> Optional[RunRecord]:
221
+ async with aiosqlite.connect(self.db_path) as db:
222
+ await db.execute("PRAGMA foreign_keys=ON;")
223
+ row = await db.execute_fetchone(
224
+ """
225
+ SELECT run_id, mode, status, created_at, ended_at, title, input_summary, config_json, sealed_at
226
+ FROM runs
227
+ WHERE run_id = ?;
228
+ """,
229
+ (run_id,),
230
+ )
231
+ if not row:
232
+ return None
233
+
234
+ message_rows = await db.execute_fetchall(
235
+ """
236
+ SELECT message_index, role, persona_label, content, timestamp
237
+ FROM run_messages
238
+ WHERE run_id = ?
239
+ ORDER BY message_index ASC;
240
+ """,
241
+ (run_id,),
242
+ )
243
+ analysis_rows = await db.execute_fetchall(
244
+ """
245
+ SELECT analysis_key, result_json
246
+ FROM run_analyses
247
+ WHERE run_id = ?;
248
+ """,
249
+ (run_id,),
250
+ )
251
+ snapshot_rows = await db.execute_fetchall(
252
+ """
253
+ SELECT role, persona_id, persona_version_id, snapshot_json
254
+ FROM run_persona_snapshots
255
+ WHERE run_id = ?;
256
+ """,
257
+ (run_id,),
258
+ )
259
+
260
+ messages: List[Dict[str, Any]] = []
261
+ for (idx, role, persona, content, ts) in message_rows:
262
+ messages.append(
263
+ {
264
+ "index": idx,
265
+ "role": role,
266
+ "persona": persona,
267
+ "content": content,
268
+ "timestamp": ts,
269
+ }
270
+ )
271
+
272
+ analyses: Dict[str, Dict[str, Any]] = {}
273
+ for (analysis_key, result_json) in analysis_rows:
274
+ try:
275
+ analyses[str(analysis_key)] = json.loads(result_json or "{}")
276
+ except Exception:
277
+ analyses[str(analysis_key)] = {}
278
+
279
+ persona_snapshots: Dict[str, Dict[str, Any]] = {}
280
+ for (role, persona_id, persona_version_id, snapshot_json) in snapshot_rows:
281
+ try:
282
+ snapshot = json.loads(snapshot_json or "{}")
283
+ except Exception:
284
+ snapshot = {}
285
+ persona_snapshots[str(role)] = {
286
+ "persona_id": persona_id,
287
+ "persona_version_id": persona_version_id,
288
+ "snapshot": snapshot,
289
+ }
290
+
291
+ try:
292
+ config = json.loads(row[7] or "{}")
293
+ except Exception:
294
+ config = {}
295
+
296
+ return RunRecord(
297
+ run_id=row[0],
298
+ mode=row[1],
299
+ status=row[2],
300
+ created_at=row[3],
301
+ ended_at=row[4],
302
+ title=row[5],
303
+ input_summary=row[6],
304
+ config=config,
305
+ sealed_at=row[8],
306
+ messages=messages,
307
+ analyses=analyses,
308
+ persona_snapshots=persona_snapshots,
309
+ )
310
+
config/settings.py CHANGED
@@ -59,12 +59,26 @@ class FrontendSettings(BaseSettings):
59
  )
60
 
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  class AppSettings(BaseSettings):
63
  """Aggregate configuration exposed to the application."""
64
 
65
  api: APISettings = APISettings()
66
  llm: LLMSettings = LLMSettings()
67
  frontend: FrontendSettings = FrontendSettings()
 
68
  log_level: str = "INFO"
69
 
70
  model_config = SettingsConfigDict(
 
59
  )
60
 
61
 
62
+ class DBSettings(BaseSettings):
63
+ """Configuration for persistent storage."""
64
+
65
+ path: str = ".localdata/converta.db"
66
+
67
+ model_config = SettingsConfigDict(
68
+ env_prefix="DB_",
69
+ env_file=".env",
70
+ env_file_encoding="utf-8",
71
+ extra="ignore",
72
+ )
73
+
74
+
75
  class AppSettings(BaseSettings):
76
  """Aggregate configuration exposed to the application."""
77
 
78
  api: APISettings = APISettings()
79
  llm: LLMSettings = LLMSettings()
80
  frontend: FrontendSettings = FrontendSettings()
81
+ db: DBSettings = DBSettings()
82
  log_level: str = "INFO"
83
 
84
  model_config = SettingsConfigDict(
docs/development.md CHANGED
@@ -77,7 +77,7 @@ The UI also includes a **Configuration** view that lets you select personas and
77
 
78
  ## Notes on Persistence (HF)
79
 
80
- Hugging Face Spaces provide a persistent volume (typically mounted at `/data` in Docker Spaces). This repo does not yet persist app data there; future work (persona editing + conversation history) should store shared artifacts under `/data` and treat repo YAML under `data/` as defaults.
81
 
82
  ## Roadmap & Next Steps
83
 
 
77
 
78
  ## Notes on Persistence (HF)
79
 
80
+ Hugging Face Spaces provide a persistent volume (typically mounted at `/data` in Docker Spaces). This repo persists sealed runs (conversation finished + analysis succeeded) to SQLite at `DB_PATH` (recommended on HF: `/data/converta/converta.db`).
81
 
82
  ## Roadmap & Next Steps
83
 
docs/hf.md CHANGED
@@ -18,6 +18,7 @@ In Space → Settings → Variables and secrets:
18
  - `LLM_MODEL`: e.g. `google/gemini-3-flash-preview`
19
  - `LLM_SITE_URL`: `https://huggingface.co/spaces/MikelWL/ConverTA` (optional)
20
  - `LLM_APP_NAME`: `ConverTA` (optional)
 
21
  - `FRONTEND_WEBSOCKET_URL`: `ws://127.0.0.1:7860/api/ws/conversation`
22
  - `FRONTEND_BACKEND_BASE_URL`: `http://127.0.0.1:7860/api` (optional)
23
 
 
18
  - `LLM_MODEL`: e.g. `google/gemini-3-flash-preview`
19
  - `LLM_SITE_URL`: `https://huggingface.co/spaces/MikelWL/ConverTA` (optional)
20
  - `LLM_APP_NAME`: `ConverTA` (optional)
21
+ - `DB_PATH`: `/data/converta/converta.db`
22
  - `FRONTEND_WEBSOCKET_URL`: `ws://127.0.0.1:7860/api/ws/conversation`
23
  - `FRONTEND_BACKEND_BASE_URL`: `http://127.0.0.1:7860/api` (optional)
24
 
docs/overview.md CHANGED
@@ -59,8 +59,9 @@ These settings are currently stored in the browser (local-only) and apply to the
59
 
60
  ## Export & Persistence Notes
61
 
62
- - Exports (Excel + JSON) are generated on demand from the current UI transcript + analysis state.
63
- - Completed conversations are not persisted server-side yet; persistent history is a future roadmap item.
 
64
 
65
  ## Access Control (Prototype)
66
 
 
59
 
60
  ## Export & Persistence Notes
61
 
62
+ - Sealed runs (conversation finished + analysis succeeded) are persisted to `DB_PATH` (HF Spaces should use `/data/...`).
63
+ - AI↔AI runs stopped by the user are not sealed and are not persisted.
64
+ - Exports (Excel + JSON) are generated from server-canonical persisted run data when available (with a client-side fallback).
65
 
66
  ## Access Control (Prototype)
67
 
docs/persistence.md CHANGED
@@ -342,7 +342,6 @@ Recommended UI structure:
342
  2. Reload history via API and confirm:
343
  - transcript message count and ordering match the original UI
344
  - analysis boxes match the original output
345
- 3. Edit a persona, run a new session, and confirm:
346
- - the new run uses the updated persona
347
- - the old run still replays with the old persona snapshot (no drift)
348
- 4. Restart the container/app and confirm history remains available.
 
342
  2. Reload history via API and confirm:
343
  - transcript message count and ordering match the original UI
344
  - analysis boxes match the original output
345
+ 3. Stop an AI↔AI session mid-run and confirm it does **not** appear in history (not sealed → not persisted).
346
+ 4. Export a completed run via API and confirm the file downloads from `/api/runs/{run_id}/export/*` (server-canonical).
347
+ 5. Restart the container/app and confirm history remains available.
 
frontend/pages/main_page.py CHANGED
@@ -187,6 +187,9 @@ def get_main_page_html(auth_enabled: bool = False) -> str:
187
  const conversationIdRef = useRef(null); // AI↔AI live session id
188
  const humanConversationIdRef = useRef(null); // Human↔Surveyor live session id
189
  const analysisConversationIdRef = useRef(null); // Upload Text analysis id
 
 
 
190
  const liveKindRef = useRef('main'); // main|human
191
  const transcriptContainerRef = useRef(null);
192
  const stickToBottomRef = useRef(true);
@@ -257,8 +260,10 @@ def get_main_page_html(auth_enabled: bool = False) -> str:
257
  const id = kind === 'human' ? `human_conv_${Date.now()}` : `react_conv_${Date.now()}`;
258
  if (kind === 'human') {
259
  humanConversationIdRef.current = id;
 
260
  } else {
261
  conversationIdRef.current = id;
 
262
  }
263
  const wsScheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
264
  const token = loadSessionToken();
@@ -335,6 +340,13 @@ def get_main_page_html(auth_enabled: bool = False) -> str:
335
  }
336
  }
337
  else if (message.type === 'resource_agent_result') {
 
 
 
 
 
 
 
338
  if (liveKindRef.current === 'human') {
339
  setHumanResources(message.data || null);
340
  setHumanStatus('complete');
@@ -521,6 +533,7 @@ def get_main_page_html(auth_enabled: bool = False) -> str:
521
 
522
  const conversationId = `analysis_${Date.now()}`;
523
  analysisConversationIdRef.current = conversationId;
 
524
 
525
  try {
526
  const fd = new FormData();
@@ -535,6 +548,11 @@ def get_main_page_html(auth_enabled: bool = False) -> str:
535
  }
536
  const data = await res.json();
537
  analysisConversationIdRef.current = data.conversation_id || conversationId;
 
 
 
 
 
538
  setAnalysisMessages(data.messages || []);
539
  setAnalysisResources(data.resources || null);
540
  setAnalysisStatus('complete');
@@ -596,12 +614,41 @@ def get_main_page_html(auth_enabled: bool = False) -> str:
596
 
597
  const downloadExport = async (format) => {
598
  if (!activeResources || activeStatus !== 'complete') return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
599
  const conversationId = activePage === 'analyze'
600
  ? (analysisConversationIdRef.current || `analysis_${Date.now()}`)
601
  : (activePage === 'human'
602
  ? (humanConversationIdRef.current || `human_conv_${Date.now()}`)
603
  : (conversationIdRef.current || `react_conv_${Date.now()}`));
604
- const exportedAt = new Date().toISOString();
605
 
606
  const payload = {
607
  conversation_id: conversationId,
 
187
  const conversationIdRef = useRef(null); // AI↔AI live session id
188
  const humanConversationIdRef = useRef(null); // Human↔Surveyor live session id
189
  const analysisConversationIdRef = useRef(null); // Upload Text analysis id
190
+ const mainRunIdRef = useRef(null);
191
+ const humanRunIdRef = useRef(null);
192
+ const analysisRunIdRef = useRef(null);
193
  const liveKindRef = useRef('main'); // main|human
194
  const transcriptContainerRef = useRef(null);
195
  const stickToBottomRef = useRef(true);
 
260
  const id = kind === 'human' ? `human_conv_${Date.now()}` : `react_conv_${Date.now()}`;
261
  if (kind === 'human') {
262
  humanConversationIdRef.current = id;
263
+ humanRunIdRef.current = null;
264
  } else {
265
  conversationIdRef.current = id;
266
+ mainRunIdRef.current = null;
267
  }
268
  const wsScheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
269
  const token = loadSessionToken();
 
340
  }
341
  }
342
  else if (message.type === 'resource_agent_result') {
343
+ if (message.persisted && message.run_id) {
344
+ if (liveKindRef.current === 'human') {
345
+ humanRunIdRef.current = message.run_id;
346
+ } else {
347
+ mainRunIdRef.current = message.run_id;
348
+ }
349
+ }
350
  if (liveKindRef.current === 'human') {
351
  setHumanResources(message.data || null);
352
  setHumanStatus('complete');
 
533
 
534
  const conversationId = `analysis_${Date.now()}`;
535
  analysisConversationIdRef.current = conversationId;
536
+ analysisRunIdRef.current = null;
537
 
538
  try {
539
  const fd = new FormData();
 
548
  }
549
  const data = await res.json();
550
  analysisConversationIdRef.current = data.conversation_id || conversationId;
551
+ if (data.persisted && data.run_id) {
552
+ analysisRunIdRef.current = data.run_id;
553
+ } else {
554
+ analysisRunIdRef.current = null;
555
+ }
556
  setAnalysisMessages(data.messages || []);
557
  setAnalysisResources(data.resources || null);
558
  setAnalysisStatus('complete');
 
614
 
615
  const downloadExport = async (format) => {
616
  if (!activeResources || activeStatus !== 'complete') return;
617
+ const exportedAt = new Date().toISOString();
618
+ const runId = activePage === 'analyze'
619
+ ? analysisRunIdRef.current
620
+ : (activePage === 'human' ? humanRunIdRef.current : mainRunIdRef.current);
621
+
622
+ if (runId) {
623
+ const endpoint = format === 'xlsx'
624
+ ? `/api/runs/${runId}/export/xlsx`
625
+ : `/api/runs/${runId}/export/json`;
626
+ const fallbackExt = format === 'xlsx' ? 'xlsx' : 'json';
627
+ const res = await authedFetch(endpoint, { method: 'GET' });
628
+ if (!res.ok) {
629
+ const msg = await res.text().catch(() => '');
630
+ console.error('Export failed:', res.status, msg);
631
+ return;
632
+ }
633
+ const blob = await res.blob();
634
+ const url = window.URL.createObjectURL(blob);
635
+ const a = document.createElement('a');
636
+ a.href = url;
637
+ const cd = res.headers.get('content-disposition') || '';
638
+ const match = cd.match(/filename=\"?([^\"]+)\"?/i);
639
+ a.download = match?.[1] || `converta_${runId}_${exportedAt.replaceAll(':', '-')}.${fallbackExt}`;
640
+ document.body.appendChild(a);
641
+ a.click();
642
+ a.remove();
643
+ window.URL.revokeObjectURL(url);
644
+ return;
645
+ }
646
+
647
  const conversationId = activePage === 'analyze'
648
  ? (analysisConversationIdRef.current || `analysis_${Date.now()}`)
649
  : (activePage === 'human'
650
  ? (humanConversationIdRef.current || `human_conv_${Date.now()}`)
651
  : (conversationIdRef.current || `react_conv_${Date.now()}`));
 
652
 
653
  const payload = {
654
  conversation_id: conversationId,
frontend/react_gradio_hybrid.py CHANGED
@@ -21,6 +21,7 @@ from websocket_manager import WebSocketManager
21
  from backend.api.main import app as backend_app
22
  from backend.api.conversation_ws import manager as backend_ws_manager
23
  from backend.api.conversation_service import initialize_conversation_service
 
24
  from backend.core.auth import (
25
  COOKIE_NAME,
26
  INTERNAL_HEADER,
@@ -48,6 +49,7 @@ app.mount("/api", backend_app)
48
  @app.on_event("startup")
49
  async def initialize_backend_services():
50
  initialize_conversation_service(backend_ws_manager, settings)
 
51
 
52
 
53
  # Enable CORS for local development
 
21
  from backend.api.main import app as backend_app
22
  from backend.api.conversation_ws import manager as backend_ws_manager
23
  from backend.api.conversation_service import initialize_conversation_service
24
+ from backend.api.storage_service import initialize_run_store
25
  from backend.core.auth import (
26
  COOKIE_NAME,
27
  INTERNAL_HEADER,
 
49
  @app.on_event("startup")
50
  async def initialize_backend_services():
51
  initialize_conversation_service(backend_ws_manager, settings)
52
+ await initialize_run_store(settings)
53
 
54
 
55
  # Enable CORS for local development