mcp-agentic-pipelines 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +93 -0
- package/README.md +258 -0
- package/package.json +70 -0
- package/packages/clinical/package.json +22 -0
- package/packages/clinical/src/index.ts +262 -0
- package/packages/clinical/tsconfig.json +13 -0
- package/packages/core/package.json +21 -0
- package/packages/core/src/config.ts +138 -0
- package/packages/core/src/errors.ts +100 -0
- package/packages/core/src/index.ts +104 -0
- package/packages/core/src/llm-config.ts +213 -0
- package/packages/core/src/logging.ts +66 -0
- package/packages/core/src/python-bridge.ts +384 -0
- package/packages/core/src/rate-limiter.ts +136 -0
- package/packages/core/src/types.ts +203 -0
- package/packages/core/src/validation.ts +101 -0
- package/packages/core/tsconfig.json +10 -0
- package/packages/deeppipe/package.json +21 -0
- package/packages/deeppipe/src/index.ts +424 -0
- package/packages/deeppipe/tsconfig.json +13 -0
- package/packages/piste/package.json +20 -0
- package/packages/piste/src/index.ts +48 -0
- package/packages/piste/tsconfig.json +13 -0
- package/packages/precis/package.json +20 -0
- package/packages/precis/src/index.ts +67 -0
- package/packages/precis/tsconfig.json +13 -0
- package/packages/server/package.json +31 -0
- package/packages/server/src/index.ts +427 -0
- package/packages/server/tsconfig.json +17 -0
- package/setup.mjs +141 -0
- package/test.mjs +337 -0
- package/vendors/clinical-intake/pipeline.mjs +349 -0
- package/vendors/clinical-intake/questions/en.txt +9 -0
- package/vendors/clinical-intake/questions/fr.txt +9 -0
- package/vendors/piste/.env.example +73 -0
- package/vendors/piste/app/core/__init__.py +4 -0
- package/vendors/piste/app/core/config.py +83 -0
- package/vendors/piste/app/core/debuglog.py +16 -0
- package/vendors/piste/app/core/middleware.py +40 -0
- package/vendors/piste/bridge_piste.py +301 -0
- package/vendors/piste/pipeline/__init__.py +4 -0
- package/vendors/piste/pipeline/compiler.py +68 -0
- package/vendors/piste/pipeline/offline/__init__.py +28 -0
- package/vendors/piste/pipeline/offline/verifaid_pipeline.py +247 -0
- package/vendors/piste/pipeline/replay.py +15 -0
- package/vendors/piste/pipeline/replay_engine.py +249 -0
- package/vendors/piste/pipeline/signatures/__init__.py +4 -0
- package/vendors/piste/pipeline/signatures/signatures.py +136 -0
- package/vendors/piste/pipeline/stage1/__init__.py +21 -0
- package/vendors/piste/pipeline/stage1/atomic_decomposer.py +61 -0
- package/vendors/piste/pipeline/stage1/check_worthiness.py +100 -0
- package/vendors/piste/pipeline/stage1/orchestrator.py +175 -0
- package/vendors/piste/pipeline/stage1/test_stage1.py +162 -0
- package/vendors/piste/pipeline/stage2/__init__.py +34 -0
- package/vendors/piste/pipeline/stage2/blind_retriever.py +303 -0
- package/vendors/piste/pipeline/stage2/canonical_mapper.py +124 -0
- package/vendors/piste/pipeline/stage2/credibility_scorer.py +85 -0
- package/vendors/piste/pipeline/stage2/orchestrator.py +311 -0
- package/vendors/piste/pipeline/stage2/query_refiner.py +88 -0
- package/vendors/piste/pipeline/stage2/search_decision.py +69 -0
- package/vendors/piste/pipeline/stage2/test_stage2.py +265 -0
- package/vendors/piste/pipeline/stage3/__init__.py +20 -0
- package/vendors/piste/pipeline/stage3/classifier.py +79 -0
- package/vendors/piste/pipeline/stage3/orchestrator.py +225 -0
- package/vendors/piste/pipeline/stage3/test_stage3.py +101 -0
- package/vendors/piste/pipeline/stage4/__init__.py +33 -0
- package/vendors/piste/pipeline/stage4/criticality_gate.py +177 -0
- package/vendors/piste/pipeline/stage4/orchestrator.py +269 -0
- package/vendors/piste/pipeline/stage4/test_stage4.py +192 -0
- package/vendors/piste/pipeline/stage4/verdict_aggregator.py +157 -0
- package/vendors/piste/requirements.txt +53 -0
- package/vendors/precis/backend/__init__.py +6 -0
- package/vendors/precis/backend/agents/__init__.py +3 -0
- package/vendors/precis/backend/agents/data_synthesis.py +105 -0
- package/vendors/precis/backend/agents/dist_free_synth.py +97 -0
- package/vendors/precis/backend/agents/exact_hash_retriever.py +327 -0
- package/vendors/precis/backend/agents/fusion_ranker.py +64 -0
- package/vendors/precis/backend/agents/guardrail.py +175 -0
- package/vendors/precis/backend/agents/query_expander.py +89 -0
- package/vendors/precis/backend/agents/radial_interpol.py +99 -0
- package/vendors/precis/backend/agents/report_generator.py +92 -0
- package/vendors/precis/backend/agents/semantic_reranker.py +135 -0
- package/vendors/precis/backend/agents/stat_anomaly.py +93 -0
- package/vendors/precis/backend/agents/vector_index.py +123 -0
- package/vendors/precis/backend/agents/veri_score.py +341 -0
- package/vendors/precis/backend/agents/work_order_extractor.py +205 -0
- package/vendors/precis/backend/api/__init__.py +3 -0
- package/vendors/precis/backend/api/routes/__init__.py +3 -0
- package/vendors/precis/backend/config.py +88 -0
- package/vendors/precis/backend/core/__init__.py +13 -0
- package/vendors/precis/backend/core/hashing.py +22 -0
- package/vendors/precis/backend/core/metrics.py +77 -0
- package/vendors/precis/backend/core/multitoken.py +166 -0
- package/vendors/precis/backend/core/pmi.py +54 -0
- package/vendors/precis/backend/core/stemming.py +74 -0
- package/vendors/precis/backend/core/tracing.py +150 -0
- package/vendors/precis/backend/data/__init__.py +3 -0
- package/vendors/precis/backend/data/chunker.py +57 -0
- package/vendors/precis/backend/data/pdf_parser.py +42 -0
- package/vendors/precis/backend/db/__init__.py +3 -0
- package/vendors/precis/backend/db/models.py +173 -0
- package/vendors/precis/backend/db/repository.py +269 -0
- package/vendors/precis/backend/llm/__init__.py +3 -0
- package/vendors/precis/backend/llm/anthropic_provider.py +39 -0
- package/vendors/precis/backend/llm/base.py +147 -0
- package/vendors/precis/backend/llm/deepseek_provider.py +43 -0
- package/vendors/precis/backend/llm/factory.py +60 -0
- package/vendors/precis/backend/llm/google_provider.py +39 -0
- package/vendors/precis/backend/llm/ollama_provider.py +54 -0
- package/vendors/precis/backend/llm/openai_provider.py +50 -0
- package/vendors/precis/backend/main.py +677 -0
- package/vendors/precis/backend/orchestrator/__init__.py +3 -0
- package/vendors/precis/backend/orchestrator/planner.py +81 -0
- package/vendors/precis/backend/orchestrator/router.py +319 -0
- package/vendors/precis/backend/orchestrator/types.py +58 -0
- package/vendors/precis/bridge_precis.py +185 -0
- package/vendors/precis/data/sample_reports/README.md +8 -0
- package/vendors/precis/data/seed_data.py +115 -0
- package/vendors/precis/requirements.txt +19 -0
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
"""© JINAN KORDAB — 2026 AI HYBRID AGENTIC RETRIEVAL-AUGMENTED GENERATION RAG PIPELINE - PERSONAL PROJECT"""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import uuid
|
|
6
|
+
import time
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import AsyncGenerator, Dict, List
|
|
10
|
+
|
|
11
|
+
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, UploadFile, File
|
|
12
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
13
|
+
from fastapi.responses import HTMLResponse, JSONResponse
|
|
14
|
+
|
|
15
|
+
from backend.config import settings
|
|
16
|
+
from backend.db.repository import init_db
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@asynccontextmanager
|
|
20
|
+
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|
21
|
+
print(f"[Precis] Starting {settings.APP_NAME} v{settings.APP_VERSION}")
|
|
22
|
+
print(f"[Precis] LLM: {settings.DEFAULT_LLM_PROVIDER} | Docs: http://localhost:8000/docs")
|
|
23
|
+
|
|
24
|
+
from backend.llm.factory import LLMFactory
|
|
25
|
+
from backend.llm.deepseek_provider import DeepSeekProvider
|
|
26
|
+
|
|
27
|
+
LLMFactory.register("deepseek", DeepSeekProvider)
|
|
28
|
+
print(f"[Precis] LLM provider: deepseek")
|
|
29
|
+
|
|
30
|
+
init_db()
|
|
31
|
+
print("[Precis] Database initialized")
|
|
32
|
+
|
|
33
|
+
# Initialize vector index (FAISS) alongside hash index
|
|
34
|
+
try:
|
|
35
|
+
from backend.agents.vector_index import VectorIndex, HAS_FAISS
|
|
36
|
+
if HAS_FAISS:
|
|
37
|
+
vector_index = VectorIndex()
|
|
38
|
+
print(f"[Precis] Vector index ready (FAISS, {vector_index.dimension}-dim)")
|
|
39
|
+
else:
|
|
40
|
+
vector_index = None
|
|
41
|
+
print("[Precis] Vector index skipped (faiss-cpu not installed)")
|
|
42
|
+
except Exception as e:
|
|
43
|
+
vector_index = None
|
|
44
|
+
print(f"[Precis] Vector index skipped: {e}")
|
|
45
|
+
|
|
46
|
+
# Create empty index + re-index persisted documents from DB
|
|
47
|
+
try:
|
|
48
|
+
from backend.agents.exact_hash_retriever import NestedHashIndex
|
|
49
|
+
from backend.core.multitoken import MultiTokenExtractor
|
|
50
|
+
idx = NestedHashIndex()
|
|
51
|
+
extractor = MultiTokenExtractor()
|
|
52
|
+
|
|
53
|
+
from backend.db.repository import get_all_document_texts
|
|
54
|
+
stored = get_all_document_texts()
|
|
55
|
+
for doc in stored:
|
|
56
|
+
# Build parsed content for async extraction
|
|
57
|
+
text = doc["text"]
|
|
58
|
+
lines = text.strip().split("\n")
|
|
59
|
+
parsed = [{"page_number": 1, "elements": []}]
|
|
60
|
+
for line in lines:
|
|
61
|
+
stripped = line.strip()
|
|
62
|
+
if not stripped:
|
|
63
|
+
continue
|
|
64
|
+
parsed[0]["elements"].append({
|
|
65
|
+
"text": stripped,
|
|
66
|
+
"is_title": stripped.isupper() and len(stripped) < 80,
|
|
67
|
+
"is_header": stripped.isupper() and len(stripped) < 60,
|
|
68
|
+
"font_size": 14.0 if stripped.isupper() else 10.0,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
# ── Hash + vector indexing in PARALLEL per document ─
|
|
72
|
+
async def hash_doc():
|
|
73
|
+
return await extractor.index_document_async(doc["filename"], parsed, idx)
|
|
74
|
+
async def vec_doc():
|
|
75
|
+
if vector_index:
|
|
76
|
+
return vector_index.index_text(text, source=doc["filename"])
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
added, chunks = await asyncio.gather(hash_doc(), vec_doc())
|
|
80
|
+
idx._doc_texts[doc["filename"]] = text
|
|
81
|
+
print(f"[Precis] Hash-indexed: {doc['filename']} ({added} tokens)")
|
|
82
|
+
if vector_index:
|
|
83
|
+
print(f"[Precis] Vector-indexed: {doc['filename']} ({chunks} chunks)")
|
|
84
|
+
if stored:
|
|
85
|
+
print(f"[Precis] {len(stored)} persisted documents restored")
|
|
86
|
+
except Exception as e:
|
|
87
|
+
print(f"[Precis] Seed error: {e}")
|
|
88
|
+
|
|
89
|
+
# Store singletons for query access
|
|
90
|
+
import backend.main as _main
|
|
91
|
+
_main._vector_index = vector_index
|
|
92
|
+
_main._demo_index = idx
|
|
93
|
+
yield
|
|
94
|
+
print("[Precis] Shutting down...")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
app = FastAPI(title=settings.APP_NAME, version=settings.APP_VERSION,
|
|
98
|
+
description="Precis — Multi-agent orchestration platform with LLM-based planning and deterministic execution.",
|
|
99
|
+
docs_url="/docs", lifespan=lifespan)
|
|
100
|
+
|
|
101
|
+
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True,
|
|
102
|
+
allow_methods=["*"], allow_headers=["*"])
|
|
103
|
+
|
|
104
|
+
# Track active WebSocket connections for trace streaming
|
|
105
|
+
_active_ws: Dict[str, WebSocket] = {}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@app.get("/", response_class=HTMLResponse)
|
|
109
|
+
async def root():
|
|
110
|
+
"""Serve the lightweight Precis UI."""
|
|
111
|
+
ui_path = Path(__file__).parent.parent / "frontend" / "src" / "app" / "index.html"
|
|
112
|
+
if ui_path.exists():
|
|
113
|
+
return HTMLResponse(ui_path.read_text(encoding="utf-8"))
|
|
114
|
+
return HTMLResponse("<h1>Precis API</h1><p>UI not found. Use /docs for API reference.</p>")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@app.get("/health", tags=["System"])
|
|
118
|
+
async def health_check():
|
|
119
|
+
return JSONResponse({"status": "healthy", "app": settings.APP_NAME,
|
|
120
|
+
"version": settings.APP_VERSION})
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@app.post("/query", tags=["Query"])
|
|
124
|
+
async def process_query(query: dict):
|
|
125
|
+
"""Execute a query through the full agent pipeline. Streams trace to WebSocket."""
|
|
126
|
+
from backend.orchestrator.types import TaskType
|
|
127
|
+
from backend.orchestrator.planner import PlannerAgent
|
|
128
|
+
from backend.orchestrator.router import RouterAgent, AgentRegistry, AgentRegistryEntry
|
|
129
|
+
from backend.llm.factory import LLMFactory
|
|
130
|
+
from backend.core.tracing import TraceCollector, TraceEventType
|
|
131
|
+
from backend.agents.exact_hash_retriever import NestedHashIndex
|
|
132
|
+
from backend.agents.veri_score import VeriScoreEvaluator
|
|
133
|
+
from backend.agents.guardrail import GuardrailAgent, GuardrailAction
|
|
134
|
+
from backend.agents.report_generator import ReportGenerator
|
|
135
|
+
|
|
136
|
+
user_query = query.get("query", "")
|
|
137
|
+
session_id = query.get("session_id", str(uuid.uuid4())[:8])
|
|
138
|
+
source_filter = query.get("source_filter", None) # Optional[List[str]] — scope search to specific docs
|
|
139
|
+
search_mode = query.get("search_mode", "standard") # "fast" | "standard" | "thorough"
|
|
140
|
+
print(f"[Precis] QUERY source_filter={source_filter!r} mode={search_mode} query={user_query[:60]!r}")
|
|
141
|
+
trace = TraceCollector(query_id=f"q_{uuid.uuid4().hex[:8]}", session_id=session_id)
|
|
142
|
+
|
|
143
|
+
# Stream trace events to connected WebSocket clients
|
|
144
|
+
def stream_trace(event_dict: dict) -> None:
|
|
145
|
+
for ws in list(_active_ws.values()):
|
|
146
|
+
asyncio.create_task(ws.send_json({"type": "trace_event", "event": event_dict}))
|
|
147
|
+
trace.set_stream_callback(stream_trace)
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
llm = LLMFactory.create_default()
|
|
151
|
+
planner = PlannerAgent(llm)
|
|
152
|
+
|
|
153
|
+
tools = [{"name": "ExactHash", "description": "Multi-source retrieval — keyword hash + semantic vector fusion"},
|
|
154
|
+
{"name": "DataSynthesis", "description": "LLM-based synthesis of retrieved data into a coherent answer"}]
|
|
155
|
+
plan = await planner.plan(user_query, available_tools=tools, trace=trace)
|
|
156
|
+
|
|
157
|
+
import backend.main as _main
|
|
158
|
+
demo_index = _main._demo_index
|
|
159
|
+
|
|
160
|
+
registry = AgentRegistry()
|
|
161
|
+
registry.register(AgentRegistryEntry("ExactHash", TaskType.FACTUAL_RETRIEVAL, NestedHashIndex,
|
|
162
|
+
"Multi-source retrieval (keyword + vector)", singleton_instance=demo_index))
|
|
163
|
+
registry.register(AgentRegistryEntry("DataSynthesis", TaskType.DATA_SYNTHESIS, None,
|
|
164
|
+
"LLM synthesis of multi-source data", is_external_llm=True))
|
|
165
|
+
|
|
166
|
+
router = RouterAgent(registry, llm=llm)
|
|
167
|
+
agent_results = await router.execute_plan(plan.subtasks, source_filter=source_filter, search_mode=search_mode, trace=trace)
|
|
168
|
+
|
|
169
|
+
# Run data_synthesis subtasks through the synthesis agent
|
|
170
|
+
from backend.agents.data_synthesis import DataSynthesisAgent
|
|
171
|
+
synth = DataSynthesisAgent(llm)
|
|
172
|
+
for i, subtask in enumerate(plan.subtasks):
|
|
173
|
+
if subtask.type == TaskType.DATA_SYNTHESIS:
|
|
174
|
+
trace.span_start("DataSynthesis", "synthesize")
|
|
175
|
+
trace.event(TraceEventType.AGENT_STARTED, agent_name="DataSynthesis",
|
|
176
|
+
message=f"Synthesizing from {len(subtask.depends_on)} upstream results...")
|
|
177
|
+
upstream = [r for r in agent_results if r.subtask_id in subtask.depends_on]
|
|
178
|
+
synth_result = await synth.synthesize(subtask.query, upstream, llm=llm)
|
|
179
|
+
synth_result.subtask_id = subtask.id
|
|
180
|
+
trace.event(TraceEventType.AGENT_COMPLETED, agent_name="DataSynthesis",
|
|
181
|
+
message=f"Synthesis complete: {len(synth_result.data.get('synthesis', ''))} chars",
|
|
182
|
+
data={"success": True, "fragments": synth_result.data.get("source_fragments", 0)})
|
|
183
|
+
trace.span_end()
|
|
184
|
+
# Replace the placeholder or append
|
|
185
|
+
replaced = False
|
|
186
|
+
for j, ar in enumerate(agent_results):
|
|
187
|
+
if ar.subtask_id == subtask.id:
|
|
188
|
+
agent_results[j] = synth_result
|
|
189
|
+
replaced = True
|
|
190
|
+
break
|
|
191
|
+
if not replaced:
|
|
192
|
+
agent_results.append(synth_result)
|
|
193
|
+
|
|
194
|
+
# If no synthesis subtask was planned, create a verdict from top results
|
|
195
|
+
if not any(s.type == TaskType.DATA_SYNTHESIS for s in plan.subtasks):
|
|
196
|
+
trace.span_start("DataSynthesis", "verdict")
|
|
197
|
+
upstream = [r for r in agent_results if r.success and r.agent_name == "ExactHash"]
|
|
198
|
+
if upstream:
|
|
199
|
+
synth_result = await synth.synthesize(user_query, upstream, llm=llm)
|
|
200
|
+
synth_result.subtask_id = "verdict"
|
|
201
|
+
synth_result.agent_name = "DataSynthesis"
|
|
202
|
+
agent_results.append(synth_result)
|
|
203
|
+
trace.event(TraceEventType.AGENT_COMPLETED, agent_name="DataSynthesis",
|
|
204
|
+
message=f"Verdict generated")
|
|
205
|
+
trace.span_end()
|
|
206
|
+
|
|
207
|
+
evaluator = VeriScoreEvaluator()
|
|
208
|
+
|
|
209
|
+
# ── Build separate lists for evaluation ─────────────────
|
|
210
|
+
# source_chunks = ONLY retrieved source text (no synthesis)
|
|
211
|
+
# synthesis_text = ONLY the LLM-generated synthesis
|
|
212
|
+
# citations = extracted citation metadata per source
|
|
213
|
+
# This separation is CRITICAL: hallucination detection must
|
|
214
|
+
# compare the LLM output against source evidence, NOT against itself.
|
|
215
|
+
source_chunks: list = []
|
|
216
|
+
synthesis_parts: list = []
|
|
217
|
+
all_citations: list = []
|
|
218
|
+
|
|
219
|
+
for r in agent_results:
|
|
220
|
+
if not r.success:
|
|
221
|
+
continue
|
|
222
|
+
data = r.data
|
|
223
|
+
if not data:
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
if isinstance(data, dict):
|
|
227
|
+
# ── Retrieved source chunks ─────────────────
|
|
228
|
+
for item in data.get("results", []):
|
|
229
|
+
if not isinstance(item, dict):
|
|
230
|
+
continue
|
|
231
|
+
# Prefer surrounding context (full paragraph) over n-gram text
|
|
232
|
+
# so VeriScore has meaningful evidence to compare against.
|
|
233
|
+
txt = item.get("surrounding", "") or item.get("sentence", "") or item.get("text", "")
|
|
234
|
+
if not txt:
|
|
235
|
+
continue
|
|
236
|
+
source_chunks.append({
|
|
237
|
+
"text": str(txt),
|
|
238
|
+
"source": item.get("source", ""),
|
|
239
|
+
"page": item.get("page", 1),
|
|
240
|
+
"score": item.get("score", 0),
|
|
241
|
+
"match_type": item.get("match_type", "broad"),
|
|
242
|
+
# Preserve structural metadata from MultiToken
|
|
243
|
+
"is_title": item.get("is_title", False),
|
|
244
|
+
"is_header": item.get("is_header", False),
|
|
245
|
+
"token_type": item.get("token_type", "standard"),
|
|
246
|
+
"font_size": item.get("font_size"),
|
|
247
|
+
})
|
|
248
|
+
# ── Citations ───────────────────────────
|
|
249
|
+
src = item.get("source", "")
|
|
250
|
+
if src:
|
|
251
|
+
all_citations.append({
|
|
252
|
+
"source": src,
|
|
253
|
+
"page": item.get("page", 1),
|
|
254
|
+
"text_preview": str(txt)[:200],
|
|
255
|
+
"match_type": item.get("match_type", ""),
|
|
256
|
+
"score": item.get("score", 0),
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
# ── LLM synthesis (separate from source chunks!) ─
|
|
260
|
+
synth = data.get("synthesis", "")
|
|
261
|
+
if synth:
|
|
262
|
+
synthesis_parts.append(str(synth))
|
|
263
|
+
|
|
264
|
+
elif isinstance(data, str):
|
|
265
|
+
source_chunks.append({
|
|
266
|
+
"text": data,
|
|
267
|
+
"source": r.agent_name,
|
|
268
|
+
"match_type": "broad",
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
# ── Build evaluation inputs ──────────────────────────
|
|
272
|
+
# generated_response = ONLY the LLM synthesis (not source chunks!)
|
|
273
|
+
# retrieved_chunks = ONLY source evidence (not synthesis!)
|
|
274
|
+
generated_text = " ".join(synthesis_parts) if synthesis_parts else ""
|
|
275
|
+
if not generated_text:
|
|
276
|
+
# Fallback: if no synthesis was produced, use concatenated sources
|
|
277
|
+
generated_text = " ".join(c["text"] for c in source_chunks) if source_chunks else "(no results)"
|
|
278
|
+
generated_text = generated_text[:2000]
|
|
279
|
+
|
|
280
|
+
if not source_chunks:
|
|
281
|
+
source_chunks = [{"text": generated_text, "source": "fallback", "match_type": "broad"}]
|
|
282
|
+
|
|
283
|
+
eval_report = await evaluator.evaluate(
|
|
284
|
+
user_query,
|
|
285
|
+
source_chunks, # ← ONLY source evidence
|
|
286
|
+
generated_text, # ← ONLY synthesis (or fallback)
|
|
287
|
+
all_citations, # ← actual citations, not []
|
|
288
|
+
trace=trace,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# ── Guardrail ────────────────────────────────────────
|
|
292
|
+
guard = GuardrailAgent()
|
|
293
|
+
guard_result = await guard.validate(
|
|
294
|
+
generated_text,
|
|
295
|
+
source_chunks,
|
|
296
|
+
user_query,
|
|
297
|
+
eval_report,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
# ── Honour the guardrail action ──────────────────────
|
|
301
|
+
if guard_result.action.value == "block":
|
|
302
|
+
trace.complete("blocked")
|
|
303
|
+
blocked_result = {
|
|
304
|
+
"status": "blocked",
|
|
305
|
+
"trace_id": trace.trace_id,
|
|
306
|
+
"guardrail": {
|
|
307
|
+
"action": "block",
|
|
308
|
+
"issues": guard_result.issues_found,
|
|
309
|
+
},
|
|
310
|
+
"message": "Response blocked by safety guardrail.",
|
|
311
|
+
}
|
|
312
|
+
for ws in list(_active_ws.values()):
|
|
313
|
+
asyncio.create_task(ws.send_json({"type": "report_ready", "data": blocked_result}))
|
|
314
|
+
return JSONResponse(blocked_result, status_code=422)
|
|
315
|
+
|
|
316
|
+
# ── Report ───────────────────────────────────────────
|
|
317
|
+
# If guardrail redacted PII, swap in the scrubbed text so the
|
|
318
|
+
# final report never contains raw PII.
|
|
319
|
+
if guard_result.action == GuardrailAction.REDACT and guard_result.redacted_response:
|
|
320
|
+
for r in agent_results:
|
|
321
|
+
if r.agent_name == "DataSynthesis" and r.data and isinstance(r.data, dict):
|
|
322
|
+
r.data["synthesis"] = guard_result.redacted_response
|
|
323
|
+
break
|
|
324
|
+
|
|
325
|
+
report_gen = ReportGenerator()
|
|
326
|
+
report = await report_gen.generate(user_query, agent_results, eval_report, guard_result)
|
|
327
|
+
|
|
328
|
+
trace.complete("success")
|
|
329
|
+
from backend.db.repository import save_trace
|
|
330
|
+
trace_id = save_trace(trace.query_id, user_query, trace.to_json(),
|
|
331
|
+
session_id=session_id, duration_ms=int(trace.get_total_duration_ms()),
|
|
332
|
+
agent_count=len(plan.subtasks), event_count=trace.get_event_count())
|
|
333
|
+
|
|
334
|
+
# Helper to extract human-readable summary from AgentResult data
|
|
335
|
+
def readable_data(r):
|
|
336
|
+
if not r.data:
|
|
337
|
+
return r.error_message or ""
|
|
338
|
+
if isinstance(r.data, str):
|
|
339
|
+
return r.data[:500]
|
|
340
|
+
if isinstance(r.data, dict):
|
|
341
|
+
parts = []
|
|
342
|
+
for item in r.data.get("results", []):
|
|
343
|
+
if isinstance(item, dict) and item.get("text"):
|
|
344
|
+
line = item["text"]
|
|
345
|
+
src = item.get("source", "")
|
|
346
|
+
pg = item.get("page", "")
|
|
347
|
+
sc = item.get("score", "")
|
|
348
|
+
ctx = item.get("surrounding", "")
|
|
349
|
+
if ctx:
|
|
350
|
+
line = ctx # Show surrounding context as primary text
|
|
351
|
+
if src:
|
|
352
|
+
line += f"\n └─ {src}, page {pg} [score: {sc}]"
|
|
353
|
+
parts.append(line)
|
|
354
|
+
synth = r.data.get("synthesis", "")
|
|
355
|
+
if synth:
|
|
356
|
+
parts.append(str(synth))
|
|
357
|
+
if parts:
|
|
358
|
+
return "\n\n".join(parts)[:1000]
|
|
359
|
+
return str(r.data)[:500]
|
|
360
|
+
return str(r.data)[:500]
|
|
361
|
+
|
|
362
|
+
result = {"status": "success", "trace_id": trace_id,
|
|
363
|
+
"plan": {"subtasks": [{"id": s.id, "type": s.type.value, "query": s.query} for s in plan.subtasks],
|
|
364
|
+
"reasoning": plan.reasoning},
|
|
365
|
+
"results": [{"agent": r.agent_name, "success": r.success,
|
|
366
|
+
"data": readable_data(r),
|
|
367
|
+
"duration_ms": r.execution_time_ms} for r in agent_results],
|
|
368
|
+
"report": report,
|
|
369
|
+
"evaluation": {"relevancy": eval_report.relevancy_score,
|
|
370
|
+
"trust": eval_report.trustworthiness_score,
|
|
371
|
+
"exhaustivity": eval_report.exhaustivity_score,
|
|
372
|
+
"hallucination_rate": eval_report.hallucination_rate,
|
|
373
|
+
"citation_coverage": eval_report.citation_coverage,
|
|
374
|
+
"flagged_issues": eval_report.flagged_issues},
|
|
375
|
+
"guardrail": {"action": guard_result.action.value,
|
|
376
|
+
"issues": guard_result.issues_found,
|
|
377
|
+
"requires_human_review": guard_result.requires_human_review}}
|
|
378
|
+
|
|
379
|
+
# Push final result to WebSocket
|
|
380
|
+
for ws in list(_active_ws.values()):
|
|
381
|
+
asyncio.create_task(ws.send_json({"type": "report_ready", "data": result}))
|
|
382
|
+
|
|
383
|
+
return result
|
|
384
|
+
|
|
385
|
+
except Exception as e:
|
|
386
|
+
trace.complete("error")
|
|
387
|
+
error_result = {"status": "error", "error": str(e), "trace_id": trace.trace_id}
|
|
388
|
+
for ws in list(_active_ws.values()):
|
|
389
|
+
asyncio.create_task(ws.send_json({"type": "report_ready", "data": error_result}))
|
|
390
|
+
return error_result
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
@app.post("/upload", tags=["Documents"])
|
|
394
|
+
async def upload_document(file: UploadFile = File(...)):
|
|
395
|
+
"""Upload a TXT or PDF file and index it into both retrieval indexes in parallel."""
|
|
396
|
+
from backend.agents.exact_hash_retriever import NestedHashIndex
|
|
397
|
+
from backend.core.multitoken import MultiTokenExtractor
|
|
398
|
+
|
|
399
|
+
content = await file.read()
|
|
400
|
+
filename = file.filename or "uploaded_document"
|
|
401
|
+
|
|
402
|
+
# Parse based on file type
|
|
403
|
+
text = _parse_upload_content(content, filename)
|
|
404
|
+
if isinstance(text, JSONResponse):
|
|
405
|
+
return text
|
|
406
|
+
|
|
407
|
+
if not text.strip():
|
|
408
|
+
return JSONResponse({"status": "error", "message": "File is empty or could not be parsed"}, status_code=400)
|
|
409
|
+
|
|
410
|
+
# Build parsed content (lines → elements) for MultiToken extraction
|
|
411
|
+
lines = text.strip().split("\n")
|
|
412
|
+
parsed = [{"page_number": 1, "elements": []}]
|
|
413
|
+
for line in lines:
|
|
414
|
+
stripped = line.strip()
|
|
415
|
+
if not stripped:
|
|
416
|
+
continue
|
|
417
|
+
parsed[0]["elements"].append({
|
|
418
|
+
"text": stripped,
|
|
419
|
+
"is_title": stripped.isupper() and len(stripped) < 80,
|
|
420
|
+
"is_header": stripped.isupper() and len(stripped) < 60,
|
|
421
|
+
"font_size": 14.0 if stripped.isupper() else 10.0,
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
# ── Run hash indexing + vector indexing in PARALLEL ──────
|
|
425
|
+
import backend.main as _main
|
|
426
|
+
index = _main._demo_index
|
|
427
|
+
extractor = MultiTokenExtractor()
|
|
428
|
+
|
|
429
|
+
async def do_hash_index():
|
|
430
|
+
return await extractor.index_document_async(filename, parsed, index)
|
|
431
|
+
|
|
432
|
+
async def do_vector_index():
|
|
433
|
+
try:
|
|
434
|
+
if _main._vector_index:
|
|
435
|
+
return _main._vector_index.index_text(text, source=filename)
|
|
436
|
+
except Exception:
|
|
437
|
+
pass
|
|
438
|
+
return 0
|
|
439
|
+
|
|
440
|
+
hash_count_task = do_hash_index()
|
|
441
|
+
vec_count_task = do_vector_index()
|
|
442
|
+
count, vec_chunks = await asyncio.gather(hash_count_task, vec_count_task)
|
|
443
|
+
|
|
444
|
+
# Also store full text for context retrieval
|
|
445
|
+
index._doc_texts[filename] = text
|
|
446
|
+
|
|
447
|
+
# Register in DB (with original text for persistence)
|
|
448
|
+
try:
|
|
449
|
+
from backend.db.repository import register_document_simple
|
|
450
|
+
register_document_simple(filename, multi_token_count=count, document_text=text)
|
|
451
|
+
except Exception:
|
|
452
|
+
pass
|
|
453
|
+
|
|
454
|
+
return JSONResponse({"status": "ok", "filename": filename,
|
|
455
|
+
"multi_tokens_indexed": count, "vector_chunks_indexed": vec_chunks})
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
@app.post("/upload/batch", tags=["Documents"])
|
|
459
|
+
async def upload_documents_batch(files: List[UploadFile] = File(...)):
|
|
460
|
+
"""Upload multiple TXT/PDF files concurrently.
|
|
461
|
+
|
|
462
|
+
Each file is parsed and indexed in parallel — N files = N concurrent
|
|
463
|
+
hash+vector indexing operations.
|
|
464
|
+
"""
|
|
465
|
+
if not files:
|
|
466
|
+
return JSONResponse({"status": "error", "message": "No files provided"}, status_code=400)
|
|
467
|
+
|
|
468
|
+
async def process_one(file: UploadFile) -> dict:
|
|
469
|
+
"""Process a single file — same logic as /upload but returns dict."""
|
|
470
|
+
try:
|
|
471
|
+
from backend.core.multitoken import MultiTokenExtractor
|
|
472
|
+
|
|
473
|
+
content = await file.read()
|
|
474
|
+
filename = file.filename or "uploaded_document"
|
|
475
|
+
text = _parse_upload_content(content, filename)
|
|
476
|
+
if isinstance(text, JSONResponse):
|
|
477
|
+
return {"filename": filename, "status": "error",
|
|
478
|
+
"error": text.body.decode() if hasattr(text, 'body') else str(text)}
|
|
479
|
+
|
|
480
|
+
if not text.strip():
|
|
481
|
+
return {"filename": filename, "status": "error", "error": "Empty file"}
|
|
482
|
+
|
|
483
|
+
lines = text.strip().split("\n")
|
|
484
|
+
parsed = [{"page_number": 1, "elements": []}]
|
|
485
|
+
for line in lines:
|
|
486
|
+
stripped = line.strip()
|
|
487
|
+
if not stripped:
|
|
488
|
+
continue
|
|
489
|
+
parsed[0]["elements"].append({
|
|
490
|
+
"text": stripped,
|
|
491
|
+
"is_title": stripped.isupper() and len(stripped) < 80,
|
|
492
|
+
"is_header": stripped.isupper() and len(stripped) < 60,
|
|
493
|
+
"font_size": 14.0 if stripped.isupper() else 10.0,
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
import backend.main as _main
|
|
497
|
+
index = _main._demo_index
|
|
498
|
+
extractor = MultiTokenExtractor()
|
|
499
|
+
|
|
500
|
+
async def do_hash(): return await extractor.index_document_async(filename, parsed, index)
|
|
501
|
+
async def do_vec():
|
|
502
|
+
try:
|
|
503
|
+
if _main._vector_index:
|
|
504
|
+
return _main._vector_index.index_text(text, source=filename)
|
|
505
|
+
except Exception:
|
|
506
|
+
pass
|
|
507
|
+
return 0
|
|
508
|
+
|
|
509
|
+
count, vec_chunks = await asyncio.gather(do_hash(), do_vec())
|
|
510
|
+
index._doc_texts[filename] = text
|
|
511
|
+
|
|
512
|
+
try:
|
|
513
|
+
from backend.db.repository import register_document_simple
|
|
514
|
+
register_document_simple(filename, multi_token_count=count, document_text=text)
|
|
515
|
+
except Exception:
|
|
516
|
+
pass
|
|
517
|
+
|
|
518
|
+
return {"filename": filename, "status": "ok",
|
|
519
|
+
"multi_tokens_indexed": count, "vector_chunks_indexed": vec_chunks}
|
|
520
|
+
except Exception as e:
|
|
521
|
+
return {"filename": file.filename or "unknown", "status": "error", "error": str(e)}
|
|
522
|
+
|
|
523
|
+
# ── Process ALL files concurrently ────────────────────────
|
|
524
|
+
results = await asyncio.gather(*(process_one(f) for f in files))
|
|
525
|
+
|
|
526
|
+
ok_count = sum(1 for r in results if r.get("status") == "ok")
|
|
527
|
+
return JSONResponse({
|
|
528
|
+
"status": "ok",
|
|
529
|
+
"total": len(results),
|
|
530
|
+
"succeeded": ok_count,
|
|
531
|
+
"failed": len(results) - ok_count,
|
|
532
|
+
"results": results,
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
# ── Shared helper: parse raw bytes → text ───────────────────────────
|
|
537
|
+
|
|
538
|
+
def _parse_upload_content(content: bytes, filename: str):
|
|
539
|
+
"""Parse uploaded bytes into text. Returns str or JSONResponse on error."""
|
|
540
|
+
text = ""
|
|
541
|
+
if filename.lower().endswith(".pdf"):
|
|
542
|
+
try:
|
|
543
|
+
import fitz
|
|
544
|
+
doc = fitz.open(stream=content, filetype="pdf")
|
|
545
|
+
for page in doc:
|
|
546
|
+
text += page.get_text() + "\n"
|
|
547
|
+
doc.close()
|
|
548
|
+
except ImportError:
|
|
549
|
+
return JSONResponse(
|
|
550
|
+
{"status": "error", "message": "PDF parsing unavailable (pymupdf not installed)"},
|
|
551
|
+
status_code=500,
|
|
552
|
+
)
|
|
553
|
+
elif filename.lower().endswith(".txt"):
|
|
554
|
+
text = content.decode("utf-8", errors="replace")
|
|
555
|
+
else:
|
|
556
|
+
return JSONResponse(
|
|
557
|
+
{"status": "error", "message": "Only .txt and .pdf files accepted"},
|
|
558
|
+
status_code=400,
|
|
559
|
+
)
|
|
560
|
+
return text
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
@app.post("/work-orders/extract", tags=["Work Orders"])
|
|
564
|
+
async def extract_work_order(file: UploadFile = File(...)):
|
|
565
|
+
"""Upload a work order PDF/TXT and extract structured fields into the database."""
|
|
566
|
+
from backend.agents.work_order_extractor import WorkOrderExtractor
|
|
567
|
+
import json
|
|
568
|
+
|
|
569
|
+
content = await file.read()
|
|
570
|
+
filename = file.filename or "work_order"
|
|
571
|
+
|
|
572
|
+
text = ""
|
|
573
|
+
if filename.lower().endswith(".pdf"):
|
|
574
|
+
try:
|
|
575
|
+
import fitz, io
|
|
576
|
+
doc = fitz.open(stream=content, filetype="pdf")
|
|
577
|
+
for page in doc:
|
|
578
|
+
text += page.get_text() + "\n"
|
|
579
|
+
doc.close()
|
|
580
|
+
except ImportError:
|
|
581
|
+
return JSONResponse({"status": "error", "message": "PDF parsing unavailable"}, status_code=500)
|
|
582
|
+
elif filename.lower().endswith(".txt"):
|
|
583
|
+
text = content.decode("utf-8", errors="replace")
|
|
584
|
+
else:
|
|
585
|
+
return JSONResponse({"status": "error", "message": "Only .txt and .pdf accepted"}, status_code=400)
|
|
586
|
+
|
|
587
|
+
if not text.strip():
|
|
588
|
+
return JSONResponse({"status": "error", "message": "Empty file"}, status_code=400)
|
|
589
|
+
|
|
590
|
+
extractor = WorkOrderExtractor()
|
|
591
|
+
wo = extractor.extract(text, source_file=filename)
|
|
592
|
+
|
|
593
|
+
# Save to database
|
|
594
|
+
from backend.db.repository import save_work_order
|
|
595
|
+
wo_id = save_work_order(wo)
|
|
596
|
+
|
|
597
|
+
return JSONResponse({
|
|
598
|
+
"status": "ok",
|
|
599
|
+
"work_order_id": wo_id,
|
|
600
|
+
"tail_number": wo.tail_number,
|
|
601
|
+
"work_order_number": wo.work_order_number,
|
|
602
|
+
"date": wo.date,
|
|
603
|
+
"aircraft_model": wo.aircraft_model,
|
|
604
|
+
"part_numbers": wo.part_numbers,
|
|
605
|
+
"mechanic_id": wo.mechanic_id,
|
|
606
|
+
"station": wo.station,
|
|
607
|
+
"hours_worked": wo.hours_worked,
|
|
608
|
+
"inspector_stamp": wo.inspector_stamp,
|
|
609
|
+
"ad_sb_references": wo.ad_sb_references,
|
|
610
|
+
"fields_extracted": len(wo.extracted_fields),
|
|
611
|
+
"fields_detail": [{"field": f.field_name, "value": f.value, "confidence": f.confidence}
|
|
612
|
+
for f in wo.extracted_fields[:20]],
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
@app.get("/work-orders", tags=["Work Orders"])
|
|
617
|
+
async def list_work_orders(tail_number: str = "", mechanic_id: str = "", limit: int = 50):
|
|
618
|
+
"""Query extracted work orders by tail number, mechanic, or list all."""
|
|
619
|
+
from backend.db.repository import query_work_orders
|
|
620
|
+
results = query_work_orders(tail_number=tail_number, mechanic_id=mechanic_id, limit=limit)
|
|
621
|
+
return JSONResponse(results)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
@app.get("/documents", tags=["Documents"])
|
|
625
|
+
async def list_documents():
|
|
626
|
+
"""List all documents currently indexed."""
|
|
627
|
+
from backend.db.repository import get_all_documents
|
|
628
|
+
docs = get_all_documents()
|
|
629
|
+
return JSONResponse(docs)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
@app.get("/debug/stem", tags=["Debug"])
|
|
633
|
+
async def debug_stem(q: str = ""):
|
|
634
|
+
"""Show how a query is stemmed (for debugging 0-result issues)."""
|
|
635
|
+
from backend.core.stemming import PrecisStemmer
|
|
636
|
+
stemmer = PrecisStemmer()
|
|
637
|
+
raw = q.lower().split()
|
|
638
|
+
stemmed = stemmer.stem_tokens(raw)
|
|
639
|
+
return JSONResponse({"raw_tokens": raw, "stemmed_tokens": stemmed})
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
@app.get("/debug/search", tags=["Debug"])
|
|
643
|
+
async def debug_search(q: str = ""):
|
|
644
|
+
"""Run a direct hybrid_search and return results (bypasses planner)."""
|
|
645
|
+
from backend.core.stemming import PrecisStemmer
|
|
646
|
+
stemmer = PrecisStemmer()
|
|
647
|
+
raw = q.lower().split()
|
|
648
|
+
stemmed = tuple(stemmer.stem_tokens(raw))
|
|
649
|
+
import backend.main as _main
|
|
650
|
+
index = _main._demo_index
|
|
651
|
+
results = index.hybrid_search(stemmed)
|
|
652
|
+
return JSONResponse({
|
|
653
|
+
"query": q,
|
|
654
|
+
"stemmed_tokens": list(stemmed),
|
|
655
|
+
"result_count": len(results),
|
|
656
|
+
"results": [{"tokens": list(r.multitoken.tokens),
|
|
657
|
+
"source": r.multitoken.source_doc,
|
|
658
|
+
"score": r.relevance_score,
|
|
659
|
+
"match_type": r.match_type}
|
|
660
|
+
for r in results[:10]]
|
|
661
|
+
})
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
@app.websocket("/ws")
|
|
665
|
+
async def ws_handler(websocket: WebSocket):
|
|
666
|
+
await websocket.accept()
|
|
667
|
+
ws_id = str(uuid.uuid4())[:8]
|
|
668
|
+
_active_ws[ws_id] = websocket
|
|
669
|
+
try:
|
|
670
|
+
while True:
|
|
671
|
+
data = await websocket.receive_json()
|
|
672
|
+
if data.get("action") == "query":
|
|
673
|
+
asyncio.create_task(process_query(data))
|
|
674
|
+
except (WebSocketDisconnect, Exception):
|
|
675
|
+
pass
|
|
676
|
+
finally:
|
|
677
|
+
_active_ws.pop(ws_id, None)
|