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.
- package/.python-version +1 -0
- package/backend/.env.example +65 -0
- package/backend/alembic/env.py +63 -0
- package/backend/alembic/script.py.mako +26 -0
- package/backend/alembic/versions/2a9c8f1d0e7b_multimodal_kb_schema.py +279 -0
- package/backend/alembic/versions/3c1d2e4f5a6b_sqlite_vec_embeddings.py +58 -0
- package/backend/alembic/versions/4e8b0c2d1a3f_document_links.py +50 -0
- package/backend/alembic/versions/6a0b1c2d3e4f_link_expansion_dedupe_columns.py +49 -0
- package/backend/alembic/versions/7d8e9f0a1b2c_document_chunks.py +70 -0
- package/backend/alembic/versions/8f2a1c0d9e3b_initial_empty_revision.py +22 -0
- package/backend/alembic/versions/9f0a1b2c3d4e_entity_mentions_cooccurrence.py +123 -0
- package/backend/alembic/versions/b1c2d3e4f5a6_pipeline_dedupe_dlq.py +99 -0
- package/backend/alembic/versions/c2d3e4f5061a_chat_sessions_messages.py +59 -0
- package/backend/alembic.ini +42 -0
- package/backend/app/__init__.py +0 -0
- package/backend/app/config.py +337 -0
- package/backend/app/connectors/__init__.py +13 -0
- package/backend/app/connectors/base.py +39 -0
- package/backend/app/connectors/builtins.py +51 -0
- package/backend/app/connectors/playwright_session.py +146 -0
- package/backend/app/connectors/registry.py +68 -0
- package/backend/app/connectors/thread_expansion/__init__.py +33 -0
- package/backend/app/connectors/thread_expansion/fakes.py +154 -0
- package/backend/app/connectors/thread_expansion/models.py +113 -0
- package/backend/app/connectors/thread_expansion/reddit.py +53 -0
- package/backend/app/connectors/thread_expansion/twitter.py +49 -0
- package/backend/app/db.py +5 -0
- package/backend/app/dependencies.py +34 -0
- package/backend/app/logging_config.py +35 -0
- package/backend/app/main.py +97 -0
- package/backend/app/middleware/__init__.py +0 -0
- package/backend/app/middleware/gateway_identity.py +17 -0
- package/backend/app/middleware/openapi_gateway.py +71 -0
- package/backend/app/middleware/request_id.py +23 -0
- package/backend/app/openapi_config.py +126 -0
- package/backend/app/routers/__init__.py +0 -0
- package/backend/app/routers/admin_pipeline.py +123 -0
- package/backend/app/routers/chat.py +206 -0
- package/backend/app/routers/chunks.py +36 -0
- package/backend/app/routers/entity_extract.py +31 -0
- package/backend/app/routers/example.py +8 -0
- package/backend/app/routers/gemini_embed.py +58 -0
- package/backend/app/routers/health.py +28 -0
- package/backend/app/routers/ingestion.py +146 -0
- package/backend/app/routers/link_expansion.py +34 -0
- package/backend/app/routers/pipeline_status.py +304 -0
- package/backend/app/routers/query.py +63 -0
- package/backend/app/routers/vectors.py +63 -0
- package/backend/app/schemas/__init__.py +0 -0
- package/backend/app/schemas/canonical.py +44 -0
- package/backend/app/schemas/chat.py +50 -0
- package/backend/app/schemas/ingest.py +29 -0
- package/backend/app/schemas/query.py +153 -0
- package/backend/app/schemas/vectors.py +56 -0
- package/backend/app/services/__init__.py +0 -0
- package/backend/app/services/chat_store.py +152 -0
- package/backend/app/services/chunking/__init__.py +3 -0
- package/backend/app/services/chunking/llm_boundaries.py +63 -0
- package/backend/app/services/chunking/schemas.py +30 -0
- package/backend/app/services/chunking/semantic_chunk.py +178 -0
- package/backend/app/services/chunking/splitters.py +214 -0
- package/backend/app/services/embeddings/__init__.py +20 -0
- package/backend/app/services/embeddings/build_inputs.py +140 -0
- package/backend/app/services/embeddings/dlq.py +128 -0
- package/backend/app/services/embeddings/gemini_api.py +207 -0
- package/backend/app/services/embeddings/persist.py +74 -0
- package/backend/app/services/embeddings/types.py +32 -0
- package/backend/app/services/embeddings/worker.py +224 -0
- package/backend/app/services/entities/__init__.py +12 -0
- package/backend/app/services/entities/gliner_extract.py +63 -0
- package/backend/app/services/entities/llm_extract.py +94 -0
- package/backend/app/services/entities/pipeline.py +179 -0
- package/backend/app/services/entities/spacy_extract.py +63 -0
- package/backend/app/services/entities/types.py +15 -0
- package/backend/app/services/gemini_chat.py +113 -0
- package/backend/app/services/hooks/__init__.py +3 -0
- package/backend/app/services/hooks/post_ingest.py +186 -0
- package/backend/app/services/ingestion/__init__.py +0 -0
- package/backend/app/services/ingestion/persist.py +188 -0
- package/backend/app/services/integrations_remote.py +91 -0
- package/backend/app/services/link_expansion/__init__.py +3 -0
- package/backend/app/services/link_expansion/canonical_url.py +45 -0
- package/backend/app/services/link_expansion/domain_policy.py +26 -0
- package/backend/app/services/link_expansion/html_extract.py +72 -0
- package/backend/app/services/link_expansion/rate_limit.py +32 -0
- package/backend/app/services/link_expansion/robots.py +46 -0
- package/backend/app/services/link_expansion/schemas.py +67 -0
- package/backend/app/services/link_expansion/worker.py +458 -0
- package/backend/app/services/normalization/__init__.py +7 -0
- package/backend/app/services/normalization/normalizer.py +331 -0
- package/backend/app/services/normalization/persist_normalized.py +67 -0
- package/backend/app/services/playwright_extract/__init__.py +13 -0
- package/backend/app/services/playwright_extract/__main__.py +96 -0
- package/backend/app/services/playwright_extract/extract.py +181 -0
- package/backend/app/services/retrieval_service.py +351 -0
- package/backend/app/sqlite_ext.py +36 -0
- package/backend/app/storage/__init__.py +3 -0
- package/backend/app/storage/blobs.py +30 -0
- package/backend/app/vectorstore/__init__.py +13 -0
- package/backend/app/vectorstore/sqlite_vec_store.py +242 -0
- package/backend/backend.egg-info/PKG-INFO +18 -0
- package/backend/backend.egg-info/SOURCES.txt +93 -0
- package/backend/backend.egg-info/dependency_links.txt +1 -0
- package/backend/backend.egg-info/entry_points.txt +2 -0
- package/backend/backend.egg-info/requires.txt +15 -0
- package/backend/backend.egg-info/top_level.txt +4 -0
- package/backend/package.json +15 -0
- package/backend/pyproject.toml +52 -0
- package/backend/tests/conftest.py +40 -0
- package/backend/tests/test_chat.py +92 -0
- package/backend/tests/test_chunking.py +132 -0
- package/backend/tests/test_entities.py +170 -0
- package/backend/tests/test_gemini_embed.py +224 -0
- package/backend/tests/test_health.py +24 -0
- package/backend/tests/test_ingest_raw.py +123 -0
- package/backend/tests/test_link_expansion.py +241 -0
- package/backend/tests/test_main.py +12 -0
- package/backend/tests/test_normalizer.py +114 -0
- package/backend/tests/test_openapi_gateway.py +40 -0
- package/backend/tests/test_pipeline_hardening.py +285 -0
- package/backend/tests/test_pipeline_status.py +71 -0
- package/backend/tests/test_playwright_extract.py +80 -0
- package/backend/tests/test_post_ingest_hooks.py +162 -0
- package/backend/tests/test_query.py +165 -0
- package/backend/tests/test_thread_expansion.py +72 -0
- package/backend/tests/test_vectors.py +85 -0
- package/backend/uv.lock +1839 -0
- package/bin/business-stack.cjs +412 -0
- package/frontend/web/.env.example +23 -0
- package/frontend/web/AGENTS.md +5 -0
- package/frontend/web/CLAUDE.md +1 -0
- package/frontend/web/README.md +36 -0
- package/frontend/web/components.json +25 -0
- package/frontend/web/next-env.d.ts +6 -0
- package/frontend/web/next.config.ts +30 -0
- package/frontend/web/package.json +65 -0
- package/frontend/web/postcss.config.mjs +7 -0
- package/frontend/web/skills-lock.json +35 -0
- package/frontend/web/src/app/account/[[...path]]/page.tsx +19 -0
- package/frontend/web/src/app/auth/[[...path]]/page.tsx +14 -0
- package/frontend/web/src/app/chat/page.tsx +725 -0
- package/frontend/web/src/app/favicon.ico +0 -0
- package/frontend/web/src/app/globals.css +563 -0
- package/frontend/web/src/app/layout.tsx +50 -0
- package/frontend/web/src/app/page.tsx +96 -0
- package/frontend/web/src/app/settings/integrations/actions.ts +74 -0
- package/frontend/web/src/app/settings/integrations/integrations-settings-form.tsx +330 -0
- package/frontend/web/src/app/settings/integrations/page.tsx +41 -0
- package/frontend/web/src/app/webhooks/alpha-alerts/route.ts +84 -0
- package/frontend/web/src/components/home-auth-panel.tsx +49 -0
- package/frontend/web/src/components/providers.tsx +50 -0
- package/frontend/web/src/lib/alpha-webhook/connectors/registry.ts +35 -0
- package/frontend/web/src/lib/alpha-webhook/connectors/types.ts +8 -0
- package/frontend/web/src/lib/alpha-webhook/connectors/wabridge-delivery.test.ts +40 -0
- package/frontend/web/src/lib/alpha-webhook/connectors/wabridge-delivery.ts +78 -0
- package/frontend/web/src/lib/alpha-webhook/connectors/wabridge.ts +30 -0
- package/frontend/web/src/lib/alpha-webhook/handler.ts +12 -0
- package/frontend/web/src/lib/alpha-webhook/signature.test.ts +33 -0
- package/frontend/web/src/lib/alpha-webhook/signature.ts +21 -0
- package/frontend/web/src/lib/alpha-webhook/types.ts +23 -0
- package/frontend/web/src/lib/auth-client.ts +23 -0
- package/frontend/web/src/lib/integrations-config.ts +125 -0
- package/frontend/web/src/lib/ui-utills.tsx +90 -0
- package/frontend/web/src/lib/utils.ts +6 -0
- package/frontend/web/tsconfig.json +36 -0
- package/frontend/web/tsconfig.tsbuildinfo +1 -0
- package/frontend/web/vitest.config.ts +14 -0
- package/gateway/.env.example +23 -0
- package/gateway/README.md +13 -0
- package/gateway/package.json +24 -0
- package/gateway/src/auth.ts +49 -0
- package/gateway/src/index.ts +141 -0
- package/gateway/src/integrations/admin.ts +19 -0
- package/gateway/src/integrations/crypto.ts +52 -0
- package/gateway/src/integrations/handlers.ts +124 -0
- package/gateway/src/integrations/keys.ts +12 -0
- package/gateway/src/integrations/store.ts +106 -0
- package/gateway/src/stack-secrets.ts +35 -0
- package/gateway/tsconfig.json +13 -0
- package/package.json +33 -0
- 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
|