Feat: persist sealed runs to SQLite
Browse files- .env.example +6 -0
- README.md +3 -0
- backend/api/analysis_routes.py +47 -1
- backend/api/conversation_service.py +60 -0
- backend/api/export_routes.py +67 -44
- backend/api/main.py +6 -0
- backend/api/run_export_routes.py +72 -0
- backend/api/run_routes.py +89 -0
- backend/api/storage_service.py +27 -0
- backend/storage/__init__.py +5 -0
- backend/storage/models.py +32 -0
- backend/storage/sqlite_run_store.py +310 -0
- config/settings.py +14 -0
- docs/development.md +1 -1
- docs/hf.md +1 -0
- docs/overview.md +3 -2
- docs/persistence.md +3 -4
- frontend/pages/main_page.py +48 -1
- frontend/react_gradio_hybrid.py +2 -0
.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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 67 |
-
|
| 68 |
-
|
| 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 |
-
|
| 124 |
-
|
| 125 |
-
|
| 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,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 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 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 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 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 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 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 |
-
|
| 187 |
|
| 188 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
-
|
| 63 |
-
-
|
|
|
|
| 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.
|
| 346 |
-
|
| 347 |
-
|
| 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
|