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.
Files changed (119) hide show
  1. package/.env.example +93 -0
  2. package/README.md +258 -0
  3. package/package.json +70 -0
  4. package/packages/clinical/package.json +22 -0
  5. package/packages/clinical/src/index.ts +262 -0
  6. package/packages/clinical/tsconfig.json +13 -0
  7. package/packages/core/package.json +21 -0
  8. package/packages/core/src/config.ts +138 -0
  9. package/packages/core/src/errors.ts +100 -0
  10. package/packages/core/src/index.ts +104 -0
  11. package/packages/core/src/llm-config.ts +213 -0
  12. package/packages/core/src/logging.ts +66 -0
  13. package/packages/core/src/python-bridge.ts +384 -0
  14. package/packages/core/src/rate-limiter.ts +136 -0
  15. package/packages/core/src/types.ts +203 -0
  16. package/packages/core/src/validation.ts +101 -0
  17. package/packages/core/tsconfig.json +10 -0
  18. package/packages/deeppipe/package.json +21 -0
  19. package/packages/deeppipe/src/index.ts +424 -0
  20. package/packages/deeppipe/tsconfig.json +13 -0
  21. package/packages/piste/package.json +20 -0
  22. package/packages/piste/src/index.ts +48 -0
  23. package/packages/piste/tsconfig.json +13 -0
  24. package/packages/precis/package.json +20 -0
  25. package/packages/precis/src/index.ts +67 -0
  26. package/packages/precis/tsconfig.json +13 -0
  27. package/packages/server/package.json +31 -0
  28. package/packages/server/src/index.ts +427 -0
  29. package/packages/server/tsconfig.json +17 -0
  30. package/setup.mjs +141 -0
  31. package/test.mjs +337 -0
  32. package/vendors/clinical-intake/pipeline.mjs +349 -0
  33. package/vendors/clinical-intake/questions/en.txt +9 -0
  34. package/vendors/clinical-intake/questions/fr.txt +9 -0
  35. package/vendors/piste/.env.example +73 -0
  36. package/vendors/piste/app/core/__init__.py +4 -0
  37. package/vendors/piste/app/core/config.py +83 -0
  38. package/vendors/piste/app/core/debuglog.py +16 -0
  39. package/vendors/piste/app/core/middleware.py +40 -0
  40. package/vendors/piste/bridge_piste.py +301 -0
  41. package/vendors/piste/pipeline/__init__.py +4 -0
  42. package/vendors/piste/pipeline/compiler.py +68 -0
  43. package/vendors/piste/pipeline/offline/__init__.py +28 -0
  44. package/vendors/piste/pipeline/offline/verifaid_pipeline.py +247 -0
  45. package/vendors/piste/pipeline/replay.py +15 -0
  46. package/vendors/piste/pipeline/replay_engine.py +249 -0
  47. package/vendors/piste/pipeline/signatures/__init__.py +4 -0
  48. package/vendors/piste/pipeline/signatures/signatures.py +136 -0
  49. package/vendors/piste/pipeline/stage1/__init__.py +21 -0
  50. package/vendors/piste/pipeline/stage1/atomic_decomposer.py +61 -0
  51. package/vendors/piste/pipeline/stage1/check_worthiness.py +100 -0
  52. package/vendors/piste/pipeline/stage1/orchestrator.py +175 -0
  53. package/vendors/piste/pipeline/stage1/test_stage1.py +162 -0
  54. package/vendors/piste/pipeline/stage2/__init__.py +34 -0
  55. package/vendors/piste/pipeline/stage2/blind_retriever.py +303 -0
  56. package/vendors/piste/pipeline/stage2/canonical_mapper.py +124 -0
  57. package/vendors/piste/pipeline/stage2/credibility_scorer.py +85 -0
  58. package/vendors/piste/pipeline/stage2/orchestrator.py +311 -0
  59. package/vendors/piste/pipeline/stage2/query_refiner.py +88 -0
  60. package/vendors/piste/pipeline/stage2/search_decision.py +69 -0
  61. package/vendors/piste/pipeline/stage2/test_stage2.py +265 -0
  62. package/vendors/piste/pipeline/stage3/__init__.py +20 -0
  63. package/vendors/piste/pipeline/stage3/classifier.py +79 -0
  64. package/vendors/piste/pipeline/stage3/orchestrator.py +225 -0
  65. package/vendors/piste/pipeline/stage3/test_stage3.py +101 -0
  66. package/vendors/piste/pipeline/stage4/__init__.py +33 -0
  67. package/vendors/piste/pipeline/stage4/criticality_gate.py +177 -0
  68. package/vendors/piste/pipeline/stage4/orchestrator.py +269 -0
  69. package/vendors/piste/pipeline/stage4/test_stage4.py +192 -0
  70. package/vendors/piste/pipeline/stage4/verdict_aggregator.py +157 -0
  71. package/vendors/piste/requirements.txt +53 -0
  72. package/vendors/precis/backend/__init__.py +6 -0
  73. package/vendors/precis/backend/agents/__init__.py +3 -0
  74. package/vendors/precis/backend/agents/data_synthesis.py +105 -0
  75. package/vendors/precis/backend/agents/dist_free_synth.py +97 -0
  76. package/vendors/precis/backend/agents/exact_hash_retriever.py +327 -0
  77. package/vendors/precis/backend/agents/fusion_ranker.py +64 -0
  78. package/vendors/precis/backend/agents/guardrail.py +175 -0
  79. package/vendors/precis/backend/agents/query_expander.py +89 -0
  80. package/vendors/precis/backend/agents/radial_interpol.py +99 -0
  81. package/vendors/precis/backend/agents/report_generator.py +92 -0
  82. package/vendors/precis/backend/agents/semantic_reranker.py +135 -0
  83. package/vendors/precis/backend/agents/stat_anomaly.py +93 -0
  84. package/vendors/precis/backend/agents/vector_index.py +123 -0
  85. package/vendors/precis/backend/agents/veri_score.py +341 -0
  86. package/vendors/precis/backend/agents/work_order_extractor.py +205 -0
  87. package/vendors/precis/backend/api/__init__.py +3 -0
  88. package/vendors/precis/backend/api/routes/__init__.py +3 -0
  89. package/vendors/precis/backend/config.py +88 -0
  90. package/vendors/precis/backend/core/__init__.py +13 -0
  91. package/vendors/precis/backend/core/hashing.py +22 -0
  92. package/vendors/precis/backend/core/metrics.py +77 -0
  93. package/vendors/precis/backend/core/multitoken.py +166 -0
  94. package/vendors/precis/backend/core/pmi.py +54 -0
  95. package/vendors/precis/backend/core/stemming.py +74 -0
  96. package/vendors/precis/backend/core/tracing.py +150 -0
  97. package/vendors/precis/backend/data/__init__.py +3 -0
  98. package/vendors/precis/backend/data/chunker.py +57 -0
  99. package/vendors/precis/backend/data/pdf_parser.py +42 -0
  100. package/vendors/precis/backend/db/__init__.py +3 -0
  101. package/vendors/precis/backend/db/models.py +173 -0
  102. package/vendors/precis/backend/db/repository.py +269 -0
  103. package/vendors/precis/backend/llm/__init__.py +3 -0
  104. package/vendors/precis/backend/llm/anthropic_provider.py +39 -0
  105. package/vendors/precis/backend/llm/base.py +147 -0
  106. package/vendors/precis/backend/llm/deepseek_provider.py +43 -0
  107. package/vendors/precis/backend/llm/factory.py +60 -0
  108. package/vendors/precis/backend/llm/google_provider.py +39 -0
  109. package/vendors/precis/backend/llm/ollama_provider.py +54 -0
  110. package/vendors/precis/backend/llm/openai_provider.py +50 -0
  111. package/vendors/precis/backend/main.py +677 -0
  112. package/vendors/precis/backend/orchestrator/__init__.py +3 -0
  113. package/vendors/precis/backend/orchestrator/planner.py +81 -0
  114. package/vendors/precis/backend/orchestrator/router.py +319 -0
  115. package/vendors/precis/backend/orchestrator/types.py +58 -0
  116. package/vendors/precis/bridge_precis.py +185 -0
  117. package/vendors/precis/data/sample_reports/README.md +8 -0
  118. package/vendors/precis/data/seed_data.py +115 -0
  119. 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)
@@ -0,0 +1,3 @@
1
+ # =============================================================================
2
+ # © JINAN KORDAB — 2026 AI HYBRID AGENTIC RETRIEVAL-AUGMENTED GENERATION RAG PIPELINE - PERSONAL PROJECT
3
+ # =============================================================================