business-stack 0.1.0

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 (181) hide show
  1. package/.python-version +1 -0
  2. package/backend/.env.example +65 -0
  3. package/backend/alembic/env.py +63 -0
  4. package/backend/alembic/script.py.mako +26 -0
  5. package/backend/alembic/versions/2a9c8f1d0e7b_multimodal_kb_schema.py +279 -0
  6. package/backend/alembic/versions/3c1d2e4f5a6b_sqlite_vec_embeddings.py +58 -0
  7. package/backend/alembic/versions/4e8b0c2d1a3f_document_links.py +50 -0
  8. package/backend/alembic/versions/6a0b1c2d3e4f_link_expansion_dedupe_columns.py +49 -0
  9. package/backend/alembic/versions/7d8e9f0a1b2c_document_chunks.py +70 -0
  10. package/backend/alembic/versions/8f2a1c0d9e3b_initial_empty_revision.py +22 -0
  11. package/backend/alembic/versions/9f0a1b2c3d4e_entity_mentions_cooccurrence.py +123 -0
  12. package/backend/alembic/versions/b1c2d3e4f5a6_pipeline_dedupe_dlq.py +99 -0
  13. package/backend/alembic/versions/c2d3e4f5061a_chat_sessions_messages.py +59 -0
  14. package/backend/alembic.ini +42 -0
  15. package/backend/app/__init__.py +0 -0
  16. package/backend/app/config.py +337 -0
  17. package/backend/app/connectors/__init__.py +13 -0
  18. package/backend/app/connectors/base.py +39 -0
  19. package/backend/app/connectors/builtins.py +51 -0
  20. package/backend/app/connectors/playwright_session.py +146 -0
  21. package/backend/app/connectors/registry.py +68 -0
  22. package/backend/app/connectors/thread_expansion/__init__.py +33 -0
  23. package/backend/app/connectors/thread_expansion/fakes.py +154 -0
  24. package/backend/app/connectors/thread_expansion/models.py +113 -0
  25. package/backend/app/connectors/thread_expansion/reddit.py +53 -0
  26. package/backend/app/connectors/thread_expansion/twitter.py +49 -0
  27. package/backend/app/db.py +5 -0
  28. package/backend/app/dependencies.py +34 -0
  29. package/backend/app/logging_config.py +35 -0
  30. package/backend/app/main.py +97 -0
  31. package/backend/app/middleware/__init__.py +0 -0
  32. package/backend/app/middleware/gateway_identity.py +17 -0
  33. package/backend/app/middleware/openapi_gateway.py +71 -0
  34. package/backend/app/middleware/request_id.py +23 -0
  35. package/backend/app/openapi_config.py +126 -0
  36. package/backend/app/routers/__init__.py +0 -0
  37. package/backend/app/routers/admin_pipeline.py +123 -0
  38. package/backend/app/routers/chat.py +206 -0
  39. package/backend/app/routers/chunks.py +36 -0
  40. package/backend/app/routers/entity_extract.py +31 -0
  41. package/backend/app/routers/example.py +8 -0
  42. package/backend/app/routers/gemini_embed.py +58 -0
  43. package/backend/app/routers/health.py +28 -0
  44. package/backend/app/routers/ingestion.py +146 -0
  45. package/backend/app/routers/link_expansion.py +34 -0
  46. package/backend/app/routers/pipeline_status.py +304 -0
  47. package/backend/app/routers/query.py +63 -0
  48. package/backend/app/routers/vectors.py +63 -0
  49. package/backend/app/schemas/__init__.py +0 -0
  50. package/backend/app/schemas/canonical.py +44 -0
  51. package/backend/app/schemas/chat.py +50 -0
  52. package/backend/app/schemas/ingest.py +29 -0
  53. package/backend/app/schemas/query.py +153 -0
  54. package/backend/app/schemas/vectors.py +56 -0
  55. package/backend/app/services/__init__.py +0 -0
  56. package/backend/app/services/chat_store.py +152 -0
  57. package/backend/app/services/chunking/__init__.py +3 -0
  58. package/backend/app/services/chunking/llm_boundaries.py +63 -0
  59. package/backend/app/services/chunking/schemas.py +30 -0
  60. package/backend/app/services/chunking/semantic_chunk.py +178 -0
  61. package/backend/app/services/chunking/splitters.py +214 -0
  62. package/backend/app/services/embeddings/__init__.py +20 -0
  63. package/backend/app/services/embeddings/build_inputs.py +140 -0
  64. package/backend/app/services/embeddings/dlq.py +128 -0
  65. package/backend/app/services/embeddings/gemini_api.py +207 -0
  66. package/backend/app/services/embeddings/persist.py +74 -0
  67. package/backend/app/services/embeddings/types.py +32 -0
  68. package/backend/app/services/embeddings/worker.py +224 -0
  69. package/backend/app/services/entities/__init__.py +12 -0
  70. package/backend/app/services/entities/gliner_extract.py +63 -0
  71. package/backend/app/services/entities/llm_extract.py +94 -0
  72. package/backend/app/services/entities/pipeline.py +179 -0
  73. package/backend/app/services/entities/spacy_extract.py +63 -0
  74. package/backend/app/services/entities/types.py +15 -0
  75. package/backend/app/services/gemini_chat.py +113 -0
  76. package/backend/app/services/hooks/__init__.py +3 -0
  77. package/backend/app/services/hooks/post_ingest.py +186 -0
  78. package/backend/app/services/ingestion/__init__.py +0 -0
  79. package/backend/app/services/ingestion/persist.py +188 -0
  80. package/backend/app/services/integrations_remote.py +91 -0
  81. package/backend/app/services/link_expansion/__init__.py +3 -0
  82. package/backend/app/services/link_expansion/canonical_url.py +45 -0
  83. package/backend/app/services/link_expansion/domain_policy.py +26 -0
  84. package/backend/app/services/link_expansion/html_extract.py +72 -0
  85. package/backend/app/services/link_expansion/rate_limit.py +32 -0
  86. package/backend/app/services/link_expansion/robots.py +46 -0
  87. package/backend/app/services/link_expansion/schemas.py +67 -0
  88. package/backend/app/services/link_expansion/worker.py +458 -0
  89. package/backend/app/services/normalization/__init__.py +7 -0
  90. package/backend/app/services/normalization/normalizer.py +331 -0
  91. package/backend/app/services/normalization/persist_normalized.py +67 -0
  92. package/backend/app/services/playwright_extract/__init__.py +13 -0
  93. package/backend/app/services/playwright_extract/__main__.py +96 -0
  94. package/backend/app/services/playwright_extract/extract.py +181 -0
  95. package/backend/app/services/retrieval_service.py +351 -0
  96. package/backend/app/sqlite_ext.py +36 -0
  97. package/backend/app/storage/__init__.py +3 -0
  98. package/backend/app/storage/blobs.py +30 -0
  99. package/backend/app/vectorstore/__init__.py +13 -0
  100. package/backend/app/vectorstore/sqlite_vec_store.py +242 -0
  101. package/backend/backend.egg-info/PKG-INFO +18 -0
  102. package/backend/backend.egg-info/SOURCES.txt +93 -0
  103. package/backend/backend.egg-info/dependency_links.txt +1 -0
  104. package/backend/backend.egg-info/entry_points.txt +2 -0
  105. package/backend/backend.egg-info/requires.txt +15 -0
  106. package/backend/backend.egg-info/top_level.txt +4 -0
  107. package/backend/package.json +15 -0
  108. package/backend/pyproject.toml +52 -0
  109. package/backend/tests/conftest.py +40 -0
  110. package/backend/tests/test_chat.py +92 -0
  111. package/backend/tests/test_chunking.py +132 -0
  112. package/backend/tests/test_entities.py +170 -0
  113. package/backend/tests/test_gemini_embed.py +224 -0
  114. package/backend/tests/test_health.py +24 -0
  115. package/backend/tests/test_ingest_raw.py +123 -0
  116. package/backend/tests/test_link_expansion.py +241 -0
  117. package/backend/tests/test_main.py +12 -0
  118. package/backend/tests/test_normalizer.py +114 -0
  119. package/backend/tests/test_openapi_gateway.py +40 -0
  120. package/backend/tests/test_pipeline_hardening.py +285 -0
  121. package/backend/tests/test_pipeline_status.py +71 -0
  122. package/backend/tests/test_playwright_extract.py +80 -0
  123. package/backend/tests/test_post_ingest_hooks.py +162 -0
  124. package/backend/tests/test_query.py +165 -0
  125. package/backend/tests/test_thread_expansion.py +72 -0
  126. package/backend/tests/test_vectors.py +85 -0
  127. package/backend/uv.lock +1839 -0
  128. package/bin/business-stack.cjs +412 -0
  129. package/frontend/web/.env.example +23 -0
  130. package/frontend/web/AGENTS.md +5 -0
  131. package/frontend/web/CLAUDE.md +1 -0
  132. package/frontend/web/README.md +36 -0
  133. package/frontend/web/components.json +25 -0
  134. package/frontend/web/next-env.d.ts +6 -0
  135. package/frontend/web/next.config.ts +30 -0
  136. package/frontend/web/package.json +65 -0
  137. package/frontend/web/postcss.config.mjs +7 -0
  138. package/frontend/web/skills-lock.json +35 -0
  139. package/frontend/web/src/app/account/[[...path]]/page.tsx +19 -0
  140. package/frontend/web/src/app/auth/[[...path]]/page.tsx +14 -0
  141. package/frontend/web/src/app/chat/page.tsx +725 -0
  142. package/frontend/web/src/app/favicon.ico +0 -0
  143. package/frontend/web/src/app/globals.css +563 -0
  144. package/frontend/web/src/app/layout.tsx +50 -0
  145. package/frontend/web/src/app/page.tsx +96 -0
  146. package/frontend/web/src/app/settings/integrations/actions.ts +74 -0
  147. package/frontend/web/src/app/settings/integrations/integrations-settings-form.tsx +330 -0
  148. package/frontend/web/src/app/settings/integrations/page.tsx +41 -0
  149. package/frontend/web/src/app/webhooks/alpha-alerts/route.ts +84 -0
  150. package/frontend/web/src/components/home-auth-panel.tsx +49 -0
  151. package/frontend/web/src/components/providers.tsx +50 -0
  152. package/frontend/web/src/lib/alpha-webhook/connectors/registry.ts +35 -0
  153. package/frontend/web/src/lib/alpha-webhook/connectors/types.ts +8 -0
  154. package/frontend/web/src/lib/alpha-webhook/connectors/wabridge-delivery.test.ts +40 -0
  155. package/frontend/web/src/lib/alpha-webhook/connectors/wabridge-delivery.ts +78 -0
  156. package/frontend/web/src/lib/alpha-webhook/connectors/wabridge.ts +30 -0
  157. package/frontend/web/src/lib/alpha-webhook/handler.ts +12 -0
  158. package/frontend/web/src/lib/alpha-webhook/signature.test.ts +33 -0
  159. package/frontend/web/src/lib/alpha-webhook/signature.ts +21 -0
  160. package/frontend/web/src/lib/alpha-webhook/types.ts +23 -0
  161. package/frontend/web/src/lib/auth-client.ts +23 -0
  162. package/frontend/web/src/lib/integrations-config.ts +125 -0
  163. package/frontend/web/src/lib/ui-utills.tsx +90 -0
  164. package/frontend/web/src/lib/utils.ts +6 -0
  165. package/frontend/web/tsconfig.json +36 -0
  166. package/frontend/web/tsconfig.tsbuildinfo +1 -0
  167. package/frontend/web/vitest.config.ts +14 -0
  168. package/gateway/.env.example +23 -0
  169. package/gateway/README.md +13 -0
  170. package/gateway/package.json +24 -0
  171. package/gateway/src/auth.ts +49 -0
  172. package/gateway/src/index.ts +141 -0
  173. package/gateway/src/integrations/admin.ts +19 -0
  174. package/gateway/src/integrations/crypto.ts +52 -0
  175. package/gateway/src/integrations/handlers.ts +124 -0
  176. package/gateway/src/integrations/keys.ts +12 -0
  177. package/gateway/src/integrations/store.ts +106 -0
  178. package/gateway/src/stack-secrets.ts +35 -0
  179. package/gateway/tsconfig.json +13 -0
  180. package/package.json +33 -0
  181. package/turbo.json +27 -0
@@ -0,0 +1,304 @@
1
+ """Read-only pipeline introspection for a single document (ingest → chunks → embed)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import UTC, datetime
7
+ from typing import Any, Literal
8
+
9
+ from fastapi import APIRouter, HTTPException
10
+ from pydantic import BaseModel, Field
11
+ from sqlalchemy import text
12
+
13
+ from app.config import get_settings
14
+ from app.dependencies import DbSession
15
+
16
+ router = APIRouter(prefix="/ingest", tags=["ingestion"])
17
+
18
+ PipelineStepState = Literal["ok", "warn", "error", "pending"]
19
+
20
+
21
+ class PipelineStepOut(BaseModel):
22
+ id: str
23
+ label: str
24
+ state: PipelineStepState
25
+ detail: str | None = None
26
+
27
+
28
+ class EmbeddingDlqOut(BaseModel):
29
+ state: str
30
+ attempt_count: int
31
+ last_error: str
32
+ next_retry_at: str | None = None
33
+ updated_at: str | None = None
34
+
35
+
36
+ class DocumentPipelineOut(BaseModel):
37
+ document_id: str
38
+ status: str
39
+ content_type: str | None = None
40
+ content_block_count: int = 0
41
+ chunk_count: int = 0
42
+ vector_row_count: int = 0
43
+ gemini_embedding_row_count: int = 0
44
+ normalization_error: dict[str, Any] | str | None = None
45
+ ingest_meta: dict[str, Any] = Field(default_factory=dict)
46
+ dlq: EmbeddingDlqOut | None = None
47
+ steps: list[PipelineStepOut]
48
+ checked_at: str
49
+
50
+
51
+ def _parse_json_obj(raw: str | None) -> dict[str, Any] | str | None:
52
+ if not raw or not raw.strip():
53
+ return None
54
+ try:
55
+ v = json.loads(raw)
56
+ return v if isinstance(v, dict) else raw
57
+ except json.JSONDecodeError:
58
+ return raw
59
+
60
+
61
+ @router.get("/documents/{document_id}/pipeline", response_model=DocumentPipelineOut)
62
+ async def get_document_pipeline(document_id: str, session: DbSession) -> DocumentPipelineOut:
63
+ settings = get_settings()
64
+ model = settings.gemini_embedding_model
65
+
66
+ row = await session.execute(
67
+ text(
68
+ "SELECT status, content_type, normalization_error, ingest_meta "
69
+ "FROM documents WHERE id = :id LIMIT 1",
70
+ ),
71
+ {"id": document_id},
72
+ )
73
+ doc = row.first()
74
+ if doc is None:
75
+ raise HTTPException(status_code=404, detail="Document not found")
76
+
77
+ status, content_type, norm_err_raw, ingest_meta_raw = doc[0], doc[1], doc[2], doc[3]
78
+ norm_parsed = _parse_json_obj(norm_err_raw)
79
+
80
+ im: dict[str, Any] = {}
81
+ if ingest_meta_raw:
82
+ try:
83
+ p = json.loads(ingest_meta_raw)
84
+ if isinstance(p, dict):
85
+ im = p
86
+ except json.JSONDecodeError:
87
+ im = {"_raw": ingest_meta_raw}
88
+
89
+ r_blocks = await session.execute(
90
+ text("SELECT COUNT(*) FROM content_blocks WHERE document_id = :id"),
91
+ {"id": document_id},
92
+ )
93
+ content_block_count = int(r_blocks.scalar_one())
94
+
95
+ r_chunks = await session.execute(
96
+ text("SELECT COUNT(*) FROM document_chunks WHERE document_id = :id"),
97
+ {"id": document_id},
98
+ )
99
+ chunk_count = int(r_chunks.scalar_one())
100
+
101
+ r_vec = await session.execute(
102
+ text("SELECT COUNT(*) FROM kb_vec_embeddings WHERE document_id = :id"),
103
+ {"id": document_id},
104
+ )
105
+ vector_row_count = int(r_vec.scalar_one())
106
+
107
+ r_emb = await session.execute(
108
+ text(
109
+ "SELECT COUNT(*) FROM embeddings WHERE document_id = :id AND model = :m",
110
+ ),
111
+ {"id": document_id, "m": model},
112
+ )
113
+ gemini_embedding_row_count = int(r_emb.scalar_one())
114
+
115
+ r_dlq = await session.execute(
116
+ text(
117
+ "SELECT state, attempt_count, last_error, next_retry_at, updated_at "
118
+ "FROM embedding_dlq WHERE document_id = :id LIMIT 1",
119
+ ),
120
+ {"id": document_id},
121
+ )
122
+ dlq_row = r_dlq.first()
123
+ dlq: EmbeddingDlqOut | None = None
124
+ if dlq_row is not None:
125
+ dlq = EmbeddingDlqOut(
126
+ state=str(dlq_row[0]),
127
+ attempt_count=int(dlq_row[1]),
128
+ last_error=str(dlq_row[2]),
129
+ next_retry_at=dlq_row[3],
130
+ updated_at=dlq_row[4],
131
+ )
132
+
133
+ embedding_err = im.get("embedding_error")
134
+ steps = _build_steps(
135
+ content_block_count=content_block_count,
136
+ chunk_count=chunk_count,
137
+ vector_row_count=vector_row_count,
138
+ doc_status=str(status),
139
+ normalization_error=norm_parsed,
140
+ dlq=dlq,
141
+ embedding_error=str(embedding_err) if embedding_err else None,
142
+ )
143
+
144
+ return DocumentPipelineOut(
145
+ document_id=document_id,
146
+ status=str(status),
147
+ content_type=str(content_type) if content_type else None,
148
+ content_block_count=content_block_count,
149
+ chunk_count=chunk_count,
150
+ vector_row_count=vector_row_count,
151
+ gemini_embedding_row_count=gemini_embedding_row_count,
152
+ normalization_error=norm_parsed,
153
+ ingest_meta=im,
154
+ dlq=dlq,
155
+ steps=steps,
156
+ checked_at=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
157
+ )
158
+
159
+
160
+ def _build_steps(
161
+ *,
162
+ content_block_count: int,
163
+ chunk_count: int,
164
+ vector_row_count: int,
165
+ doc_status: str,
166
+ normalization_error: dict[str, Any] | str | None,
167
+ dlq: EmbeddingDlqOut | None,
168
+ embedding_error: str | None,
169
+ ) -> list[PipelineStepOut]:
170
+ steps: list[PipelineStepOut] = []
171
+
172
+ steps.append(
173
+ PipelineStepOut(
174
+ id="document",
175
+ label="Document stored",
176
+ state="ok",
177
+ detail="Row exists in `documents`",
178
+ ),
179
+ )
180
+
181
+ if normalization_error is not None:
182
+ msg = (
183
+ normalization_error.get("message", str(normalization_error))
184
+ if isinstance(normalization_error, dict)
185
+ else str(normalization_error)
186
+ )
187
+ n_state: PipelineStepState = "error"
188
+ n_detail = msg[:500] + ("…" if len(msg) > 500 else "")
189
+ steps.append(
190
+ PipelineStepOut(
191
+ id="normalize",
192
+ label="Normalize → content blocks",
193
+ state=n_state,
194
+ detail=n_detail,
195
+ ),
196
+ )
197
+ elif content_block_count > 0:
198
+ steps.append(
199
+ PipelineStepOut(
200
+ id="normalize",
201
+ label="Normalize → content blocks",
202
+ state="ok",
203
+ detail=f"{content_block_count} block(s)",
204
+ ),
205
+ )
206
+ else:
207
+ steps.append(
208
+ PipelineStepOut(
209
+ id="normalize",
210
+ label="Normalize → content blocks",
211
+ state="warn",
212
+ detail="No content blocks yet (empty or not normalized)",
213
+ ),
214
+ )
215
+
216
+ if chunk_count > 0:
217
+ steps.append(
218
+ PipelineStepOut(
219
+ id="chunks",
220
+ label="Semantic chunks",
221
+ state="ok",
222
+ detail=f"{chunk_count} chunk(s)",
223
+ ),
224
+ )
225
+ elif content_block_count > 0:
226
+ steps.append(
227
+ PipelineStepOut(
228
+ id="chunks",
229
+ label="Semantic chunks",
230
+ state="error",
231
+ detail="No rows in `document_chunks` — run POST …/chunks or re-ingest",
232
+ ),
233
+ )
234
+ else:
235
+ steps.append(
236
+ PipelineStepOut(
237
+ id="chunks",
238
+ label="Semantic chunks",
239
+ state="pending",
240
+ detail="Waiting for content blocks",
241
+ ),
242
+ )
243
+
244
+ err_bits: list[str] = []
245
+ if embedding_error:
246
+ err_bits.append(embedding_error[:400])
247
+ if dlq is not None and dlq.state == "dead":
248
+ err_bits.append(f"DLQ dead after {dlq.attempt_count} attempt(s)")
249
+ elif dlq is not None:
250
+ err_bits.append(f"DLQ {dlq.state} (attempt {dlq.attempt_count})")
251
+ if dlq.last_error:
252
+ err_bits.append(dlq.last_error[:400])
253
+
254
+ if chunk_count == 0:
255
+ steps.append(
256
+ PipelineStepOut(
257
+ id="embed",
258
+ label="Embed + vector index",
259
+ state="pending",
260
+ detail="Chunks required before embedding",
261
+ ),
262
+ )
263
+ elif vector_row_count > 0:
264
+ match = vector_row_count == chunk_count
265
+ steps.append(
266
+ PipelineStepOut(
267
+ id="embed",
268
+ label="Embed + vector index",
269
+ state="ok" if match else "warn",
270
+ detail=(
271
+ f"{vector_row_count} vector row(s), {chunk_count} chunk(s)"
272
+ + ("" if match else " — counts differ; possible partial failure")
273
+ ),
274
+ ),
275
+ )
276
+ elif doc_status == "failed" or (dlq is not None and dlq.state == "dead"):
277
+ steps.append(
278
+ PipelineStepOut(
279
+ id="embed",
280
+ label="Embed + vector index",
281
+ state="error",
282
+ detail="; ".join(err_bits) if err_bits else "Embedding failed",
283
+ ),
284
+ )
285
+ elif err_bits:
286
+ steps.append(
287
+ PipelineStepOut(
288
+ id="embed",
289
+ label="Embed + vector index",
290
+ state="warn",
291
+ detail="; ".join(err_bits),
292
+ ),
293
+ )
294
+ else:
295
+ steps.append(
296
+ PipelineStepOut(
297
+ id="embed",
298
+ label="Embed + vector index",
299
+ state="pending",
300
+ detail="Queued or running — vectors not in index yet",
301
+ ),
302
+ )
303
+
304
+ return steps
@@ -0,0 +1,63 @@
1
+ from datetime import UTC, datetime
2
+
3
+ from fastapi import APIRouter, HTTPException
4
+
5
+ from app.dependencies import DbSession, SettingsDep, VectorStoreDep
6
+ from app.schemas.query import (
7
+ QueryCandidate,
8
+ QueryContext,
9
+ QueryContextMedia,
10
+ QueryContextSection,
11
+ QueryRequest,
12
+ QueryResponse,
13
+ )
14
+ from app.services.retrieval_service import run_retrieval
15
+
16
+ router = APIRouter(tags=["query"])
17
+
18
+
19
+ @router.post("/query", response_model=QueryResponse)
20
+ async def query_retrieve(
21
+ body: QueryRequest,
22
+ session: DbSession,
23
+ store: VectorStoreDep,
24
+ settings: SettingsDep,
25
+ ) -> QueryResponse:
26
+ """
27
+ Embed the user query (Gemini), run vector search with optional filters,
28
+ blend semantic / recency / source weights, hydrate chunks and documents,
29
+ and return ranked hits plus aggregated multimodal context for generation.
30
+ """
31
+ filters = (
32
+ body.filters.to_vector_filters(now=datetime.now(UTC)) if body.filters else None
33
+ )
34
+ try:
35
+ raw = await run_retrieval(
36
+ session=session,
37
+ store=store,
38
+ settings=settings,
39
+ query=body.query,
40
+ k=body.k,
41
+ filters=filters,
42
+ )
43
+ except ValueError as e:
44
+ raise HTTPException(status_code=400, detail=str(e)) from e
45
+ except RuntimeError as e:
46
+ msg = str(e)
47
+ if "Gemini API key" in msg or "GEMINI_API_KEY" in msg:
48
+ raise HTTPException(status_code=503, detail=msg) from e
49
+ raise HTTPException(status_code=502, detail=msg) from e
50
+
51
+ candidates = [QueryCandidate.model_validate(c) for c in raw["candidates"]]
52
+ ctx = raw["context"]
53
+ context = QueryContext(
54
+ combined_text=ctx["combined_text"],
55
+ sections=[QueryContextSection.model_validate(s) for s in ctx["sections"]],
56
+ media=[QueryContextMedia.model_validate(m) for m in ctx["media"]],
57
+ )
58
+ return QueryResponse(
59
+ candidates=candidates,
60
+ context=context,
61
+ embedding_model=raw["embedding_model"],
62
+ vector_candidates_considered=raw["vector_candidates_considered"],
63
+ )
@@ -0,0 +1,63 @@
1
+ from fastapi import APIRouter
2
+
3
+ from app.dependencies import VectorStoreDep
4
+ from app.schemas.vectors import (
5
+ VectorSearchHit,
6
+ VectorSearchRequest,
7
+ VectorSearchResponse,
8
+ VectorUpsertRequest,
9
+ )
10
+ from app.vectorstore import VectorMeta
11
+
12
+ router = APIRouter(prefix="/vectors", tags=["vectors"])
13
+
14
+
15
+ @router.post("/upsert")
16
+ async def upsert_vectors(
17
+ body: VectorUpsertRequest,
18
+ store: VectorStoreDep,
19
+ ) -> dict[str, str]:
20
+ metas = [
21
+ VectorMeta(
22
+ document_id=m.document_id,
23
+ chunk_id=m.chunk_id,
24
+ source_id=m.source_id,
25
+ modality=m.modality,
26
+ ingested_at=m.ingested_at,
27
+ )
28
+ for m in body.metas
29
+ ]
30
+ await store.upsert(body.embeddings, metas)
31
+ return {"status": "ok"}
32
+
33
+
34
+ @router.delete("/documents/{document_id}")
35
+ async def delete_document_vectors(
36
+ document_id: str,
37
+ store: VectorStoreDep,
38
+ ) -> dict[str, int]:
39
+ deleted = await store.delete_document(document_id)
40
+ return {"deleted": deleted}
41
+
42
+
43
+ @router.post("/search", response_model=VectorSearchResponse)
44
+ async def search_vectors(
45
+ body: VectorSearchRequest,
46
+ store: VectorStoreDep,
47
+ ) -> VectorSearchResponse:
48
+ filters = body.filters.to_filters() if body.filters else None
49
+ hits = await store.search(body.query_vector, body.k, filters)
50
+ return VectorSearchResponse(
51
+ results=[
52
+ VectorSearchHit(
53
+ rowid=h.rowid,
54
+ distance=h.distance,
55
+ document_id=h.document_id,
56
+ chunk_id=h.chunk_id,
57
+ source_id=h.source_id,
58
+ modality=h.modality,
59
+ ingested_at=h.ingested_at,
60
+ )
61
+ for h in hits
62
+ ],
63
+ )
File without changes
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Any, Literal
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field
7
+
8
+ BlockType = Literal["text", "image", "audio", "video", "document"]
9
+
10
+
11
+ class CanonicalContentBlock(BaseModel):
12
+ """One multimodal slice after normalization."""
13
+
14
+ model_config = ConfigDict(extra="forbid")
15
+
16
+ type: BlockType
17
+ data: str | None = Field(
18
+ default=None,
19
+ description="Inline UTF-8 text or small structured payload summary",
20
+ )
21
+ raw_input: str | None = Field(
22
+ default=None,
23
+ description="Storage reference e.g. blob://<sha256> or uri:https://...",
24
+ )
25
+ mime: str | None = Field(
26
+ default=None,
27
+ description="IANA media type when stored externally (e.g. image/png)",
28
+ )
29
+
30
+
31
+ class CanonicalDocument(BaseModel):
32
+ """Canonical shape for a normalized document (API + persistence source)."""
33
+
34
+ model_config = ConfigDict(extra="forbid")
35
+
36
+ id: str
37
+ source: str
38
+ timestamp: datetime
39
+ content_blocks: list[CanonicalContentBlock]
40
+ raw_content: Any
41
+ entities: list[Any] = Field(default_factory=list)
42
+ links: list[str] = Field(default_factory=list)
43
+ tags: list[str] = Field(default_factory=list)
44
+ summary: str = ""
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+ from app.schemas.query import QueryFiltersPayload
6
+
7
+
8
+ class ChatSessionCreate(BaseModel):
9
+ model_config = ConfigDict(extra="forbid")
10
+
11
+ title: str | None = Field(default=None, max_length=500)
12
+
13
+
14
+ class ChatSessionOut(BaseModel):
15
+ model_config = ConfigDict(extra="forbid")
16
+
17
+ id: str
18
+ title: str | None
19
+ created_at: str
20
+ updated_at: str
21
+
22
+
23
+ class ChatMessageOut(BaseModel):
24
+ model_config = ConfigDict(extra="forbid")
25
+
26
+ id: int
27
+ role: str
28
+ content: str
29
+ meta_json: str | None
30
+ created_at: str
31
+
32
+
33
+ class ChatCompleteRequest(BaseModel):
34
+ model_config = ConfigDict(extra="forbid")
35
+
36
+ message: str = Field(..., min_length=1, max_length=16_000)
37
+ k: int = Field(default=8, ge=1, le=50)
38
+ filters: QueryFiltersPayload | None = None
39
+
40
+
41
+ class ChatCompleteResponse(BaseModel):
42
+ model_config = ConfigDict(extra="forbid")
43
+
44
+ assistant_message_id: int
45
+ reply: str
46
+ reply_source: str = Field(
47
+ description="'model' if Gemini answered, else retrieval fallback text",
48
+ )
49
+ embedding_model: str
50
+ vector_candidates_considered: int
@@ -0,0 +1,29 @@
1
+ from datetime import datetime
2
+ from typing import Any, Literal
3
+
4
+ from pydantic import BaseModel, ConfigDict, Field
5
+
6
+ ContentTypeLiteral = Literal[
7
+ "text",
8
+ "image",
9
+ "audio",
10
+ "video",
11
+ "document",
12
+ "multimodal",
13
+ ]
14
+
15
+
16
+ class RawIngestEnvelope(BaseModel):
17
+ """Connector output envelope accepted by POST /ingest/raw."""
18
+
19
+ model_config = ConfigDict(extra="forbid")
20
+
21
+ source: str = Field(
22
+ ...,
23
+ min_length=1,
24
+ description="Logical source label (e.g. slack, filesystem)",
25
+ )
26
+ timestamp: datetime
27
+ content_type: ContentTypeLiteral
28
+ payload: Any
29
+ metadata: dict[str, Any] = Field(default_factory=dict)
@@ -0,0 +1,153 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import UTC, datetime, timedelta
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+ from app.vectorstore import VectorSearchFilters
8
+
9
+
10
+ def _parse_iso_utc(s: str) -> datetime | None:
11
+ try:
12
+ norm = s.replace("Z", "+00:00")
13
+ dt = datetime.fromisoformat(norm)
14
+ if dt.tzinfo is None:
15
+ dt = dt.replace(tzinfo=UTC)
16
+ return dt.astimezone(UTC)
17
+ except (TypeError, ValueError):
18
+ return None
19
+
20
+
21
+ def _later_iso(a: str, b: str) -> str:
22
+ """Return the ISO timestamp that is later in time (stricter lower bound)."""
23
+ da, db = _parse_iso_utc(a), _parse_iso_utc(b)
24
+ if da and db:
25
+ return a if da >= db else b
26
+ return a if da else b
27
+
28
+
29
+ class QueryFiltersPayload(BaseModel):
30
+ model_config = ConfigDict(extra="forbid")
31
+
32
+ document_id: str | None = None
33
+ source_id: int | None = None
34
+ modality: str | None = None
35
+ timestamp_min: str | None = Field(
36
+ default=None,
37
+ description="ISO-8601 lower bound on vector ingested_at",
38
+ )
39
+ timestamp_max: str | None = Field(
40
+ default=None,
41
+ description="ISO-8601 upper bound on vector ingested_at",
42
+ )
43
+ newer_than_days: float | None = Field(
44
+ default=None,
45
+ ge=0.0,
46
+ description="Convenience: require ingested_at >= now - this many days",
47
+ )
48
+
49
+ def to_vector_filters(self, *, now: datetime) -> VectorSearchFilters | None:
50
+ ts_min = self.timestamp_min
51
+ if self.newer_than_days is not None:
52
+ cutoff = (now - timedelta(days=float(self.newer_than_days))).astimezone(UTC)
53
+ cut_iso = cutoff.strftime("%Y-%m-%dT%H:%M:%SZ")
54
+ ts_min = _later_iso(ts_min, cut_iso) if ts_min else cut_iso
55
+
56
+ if (
57
+ self.document_id is None
58
+ and self.source_id is None
59
+ and self.modality is None
60
+ and ts_min is None
61
+ and self.timestamp_max is None
62
+ ):
63
+ return None
64
+ return VectorSearchFilters(
65
+ document_id=self.document_id,
66
+ source_id=self.source_id,
67
+ modality=self.modality,
68
+ timestamp_min=ts_min,
69
+ timestamp_max=self.timestamp_max,
70
+ )
71
+
72
+
73
+ class QueryRequest(BaseModel):
74
+ model_config = ConfigDict(extra="forbid")
75
+
76
+ query: str = Field(..., min_length=1, max_length=16_000)
77
+ k: int = Field(default=10, ge=1, le=100)
78
+ filters: QueryFiltersPayload | None = None
79
+
80
+
81
+ class QueryAttribution(BaseModel):
82
+ vector_rowid: int
83
+ distance: float
84
+ document_id: str
85
+ chunk_id: int
86
+ source_id: int
87
+ modality: str
88
+ ingested_at: str
89
+ source_name: str | None = None
90
+ connector_type: str | None = None
91
+ document_timestamp: str | None = None
92
+
93
+
94
+ class QueryChunkPayload(BaseModel):
95
+ ordinal: int
96
+ text: str
97
+ start_block_ordinal: int
98
+ end_block_ordinal: int
99
+ meta: str | None = None
100
+
101
+
102
+ class QueryDocumentPayload(BaseModel):
103
+ summary: str | None = None
104
+ content_type: str
105
+ timestamp: str
106
+ status: str
107
+
108
+
109
+ class QuerySourcePayload(BaseModel):
110
+ id: int
111
+ name: str
112
+ connector_type: str
113
+
114
+
115
+ class QueryCandidate(BaseModel):
116
+ rank: int
117
+ score: float
118
+ semantic_score: float
119
+ recency_score: float
120
+ source_weight: float
121
+ attribution: QueryAttribution
122
+ document: QueryDocumentPayload | None = None
123
+ chunk: QueryChunkPayload | None = None
124
+ source: QuerySourcePayload | None = None
125
+
126
+
127
+ class QueryContextSection(BaseModel):
128
+ document_id: str
129
+ chunk_id: int
130
+ chunk_ordinal: int
131
+ text: str
132
+
133
+
134
+ class QueryContextMedia(BaseModel):
135
+ document_id: str
136
+ chunk_ordinal: int
137
+ block_ordinal: int
138
+ type: str
139
+ mime: str | None = None
140
+ storage_uri: str | None = None
141
+
142
+
143
+ class QueryContext(BaseModel):
144
+ combined_text: str
145
+ sections: list[QueryContextSection]
146
+ media: list[QueryContextMedia]
147
+
148
+
149
+ class QueryResponse(BaseModel):
150
+ candidates: list[QueryCandidate]
151
+ context: QueryContext
152
+ embedding_model: str
153
+ vector_candidates_considered: int