Fix migration workflow: upload as artifact instead of scp to practice EC2

This commit is contained in:
2026-08-13 11:14:25 +07:00
parent 7ebbe1f309
commit a4819b8653
51 changed files with 6830 additions and 8 deletions
+105
View File
@@ -0,0 +1,105 @@
# apps/ai-service configuration — every setting is a field on `Settings` in
# config.py, which is the authority. Copy to `.env` and edit.
#
# cp apps/ai-service/.env.example apps/ai-service/.env
#
# Values shown are the CODE DEFAULTS from config.py unless marked otherwise.
# Production uses `.env.prod`, which is gitignored and lives on the host.
# Never commit a real secret to this file.
#
# Reference: docs/15-configuration.md
# ─── Operating mode ──────────────────────────────────────────────────────────
# These three decide which runtime graph bootstrap.py builds. See
# docs/10-rag-orchestration.md.
# `cohere-v4` = semantic query embedding against the corpus's own vector space
# (needs live AWS Bedrock).
# `disabled` = no retrieval at all. /ready still returns 200 but
# POST /v1/rag/query returns 503. This is the setting the test
# suite uses.
# No other value is accepted — bootstrap.py raises at startup.
EMBEDDING_PROVIDER=disabled
# `disabled` = retrieval-only, single-turn, verbatim source quotes.
# No RagAgent, no query understanding, no multi-turn.
# `stub` = runs the whole answer path (prompt, schema parsing,
# grounding, fallback) with NO cloud call.
# `bedrock-converse` = DeepSeek / Qwen / GLM / Nova via the Converse API.
# `bedrock-claude` = Anthropic via the Messages path.
ANSWER_PROVIDER=disabled
# Bedrock model id for the generation/understanding/entailment calls.
ANSWER_MODEL_ID=deepseek.v3.2
# ─── Vector store ────────────────────────────────────────────────────────────
QDRANT_URL=http://localhost:6333
QDRANT_COLLECTION=duocthu_v1
# Only needed for a Qdrant instance that requires auth. SECRET.
# QDRANT_API_KEY=
# Must match the `dimensions` recorded in the `<collection>__manifest` sidecar,
# or startup fails with ManifestMismatch and the process does not come up.
EMBEDDING_DIMENSIONS=1024
# Score floor for the dense routes only. The deterministic section route is an
# exact payload match and never compares against this.
EVIDENCE_MINIMUM_SCORE=0.12
# ─── PostgreSQL ──────────────────────────────────────────────────────────────
# Retrieval traces, conversation turns, answer feedback. All three writes are
# fail-open: an outage degrades memory/tracing, never an answer. SECRET.
POSTGRES_DSN=postgresql://duoc_thu:duoc_thu@localhost:5432/duoc_thu
# ─── AWS ─────────────────────────────────────────────────────────────────────
# Credentials come from the standard boto3 chain — in production, the EC2
# instance's IAM role. Do NOT put AWS keys in this file.
AWS_REGION=us-east-1
# Optional cross-encoder rerank (cohere.rerank-v3-5:0) on the similarity /
# overview fallback. Fail-open. The section route never reranks.
RERANK_ENABLED=false
# ─── Per-request budget ──────────────────────────────────────────────────────
# Checked BETWEEN provider calls; it cannot cancel a call already in flight
# (that is bounded by read_timeout=20 in adapters/bedrock_converse.py). The
# browser timeout in ChatPanel.tsx is derived from these — raising them without
# raising that will make the client give up first.
MAX_WALL_CLOCK_MS=40000
MAX_LLM_CALLS_PER_TURN=8
# ─── Drug catalog ────────────────────────────────────────────────────────────
# Defaults to a repo-relative path resolved from config.py. The container image
# flattens apps/ai-service/ into its own root, so that default is wrong there
# and .env.prod must set this explicitly (the Dockerfile bakes the file in):
# ENTITIES_PATH=./ingestion_data/drug_entities.json
# ─── Observability ───────────────────────────────────────────────────────────
METRICS_ENABLED=true
# Optional bearer token for GET /metrics. Empty = unauthenticated, which is the
# current production setting and is only safe because ai-service publishes no
# host port and Caddy proxies only `web`. SET THIS before exposing the service
# through an Ingress — metrics carry query volumes, provider failure counts and
# abstain reasons. SECRET.
METRICS_TOKEN=
# Opt-in so a deployment with no collector keeps answering. The Docker and
# Kubernetes observability profiles turn this on.
OTEL_ENABLED=false
OTEL_SERVICE_NAME=ai-service
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces
OTEL_SAMPLE_RATIO=1.0
# ─── Misc ────────────────────────────────────────────────────────────────────
APP_NAME=vsf-duoc-thu-ai-service
# Label only; also sent as `deployment.environment` on the OTel resource.
ENVIRONMENT=local
+45 -6
View File
@@ -15,6 +15,45 @@ _LEXICAL_STOPWORDS = frozenset({
})
def _vector_search_points(
client: Any,
*,
collection_name: str,
vector: list[float],
query_filter: Any,
limit: int,
) -> list[Any]:
"""Run a dense lookup across supported qdrant-client generations.
qdrant-client 1.16 removed ``QdrantClient.search`` in favour of the
universal ``query_points`` API. Developer machines can still have an
older 1.x client because the project allows ``>=1.7,<2``. Prefer the new
API when present and retain the old call only as a compatibility path;
both return scored points with payloads.
"""
query_points = getattr(client, "query_points", None)
if callable(query_points):
response = query_points(
collection_name=collection_name,
query=vector,
query_filter=query_filter,
limit=limit,
with_payload=True,
)
return list(response.points)
search = getattr(client, "search", None)
if callable(search):
return list(search(
collection_name=collection_name,
query_vector=vector,
query_filter=query_filter,
limit=limit,
with_payload=True,
))
raise RuntimeError("qdrant client exposes neither query_points nor search")
class QueryEmbedder(Protocol):
@property
def dimensions(self) -> int: ...
@@ -133,14 +172,14 @@ class QdrantRetriever:
f"query vector has {len(vector)} dimensions; "
f"expected {self._embedder.dimensions}"
)
points = self._client.search(
points = _vector_search_points(
self._client,
collection_name=self._collection_name,
query_vector=vector,
vector=vector,
query_filter=Filter(
must=[FieldCondition(key="drug_id", match=MatchValue(value=drug_id))]
),
limit=limit,
with_payload=True,
)
return [
SearchHit(_document(dict(point.payload or {})), float(point.score))
@@ -410,9 +449,10 @@ class QdrantRetriever:
f"query vector has {len(vector)} dimensions; "
f"expected {self._embedder.dimensions}"
)
points = self._client.search(
points = _vector_search_points(
self._client,
collection_name=self._collection_name,
query_vector=vector,
vector=vector,
query_filter=Filter(
must=[
FieldCondition(key="section_key", match=MatchValue(value="chi_dinh")),
@@ -420,7 +460,6 @@ class QdrantRetriever:
]
),
limit=limit * 4,
with_payload=True,
)
hits: list[SearchHit] = []
for point in points:
+28
View File
@@ -0,0 +1,28 @@
"""Test-suite defaults that must be set before any test module is imported.
`main.py` builds the entire runtime at module scope (`build_runtime(get_settings())`),
and `tests/test_api.py` imports `main`. With the default `EMBEDDING_PROVIDER=cohere-v4`
— or with a developer's `.env` selecting it — that construction opens a
`QdrantClient` and calls `get_collections()` for the corpus-manifest check, so
`pytest` fails during *collection* on any machine without a reachable Qdrant:
qdrant_client.http.exceptions.ResponseHandlingException:
[WinError 10061] No connection could be made ...
Interrupted: 1 error during collection
No unit test needs a live datastore: every test injects its own doubles, and
the one suite that does need real services (`test_live_datastores.py`) gates
itself behind `RUN_INTEGRATION=1`. Forcing the disabled provider here makes
`pytest tests -q` work out of the box instead of requiring an undocumented
environment variable.
`setdefault`, not assignment: a deliberate override (for example
`EMBEDDING_PROVIDER=cohere-v4 pytest ...` against a local Qdrant) still wins.
This runs at import time, before pytest collects any module, which is the only
point early enough — `config.get_settings()` is `lru_cache`d, so a fixture
would already be too late.
"""
import os
os.environ.setdefault("EMBEDDING_PROVIDER", "disabled")
@@ -1,3 +1,5 @@
from types import SimpleNamespace
from adapters.qdrant import QdrantRetriever, _source_refs
@@ -24,6 +26,25 @@ class _FakeScrollClient:
return [_FakePoint(p) for p in self._payloads], None
class _FakeQueryPointsClient:
"""Production qdrant-client shape (1.16+): no legacy `.search()`."""
def __init__(self, payloads: list[dict]) -> None:
self._payloads = payloads
self.kwargs = None
def query_points(self, **kwargs):
self.kwargs = kwargs
return SimpleNamespace(points=[_FakePoint(p) for p in self._payloads])
class _FakeEmbedder:
dimensions = 3
def embed_query(self, text): # noqa: ARG002
return [0.1, 0.2, 0.3]
def _chi_dinh_payload(drug_id: str, text: str) -> dict:
return {
"chunk_id": f"{drug_id}__chi_dinh__0", "drug_id": drug_id,
@@ -91,6 +112,20 @@ def test_find_by_indication_matches_a_drug_that_names_the_symptom():
assert [h.document.drug_id for h in hits] == ["paracetamol_acetaminophen"]
def test_dense_indication_fallback_uses_modern_query_points_api():
client = _FakeQueryPointsClient([
_chi_dinh_payload("colchicin", "Điều trị đợt cấp bệnh gút."),
])
retriever = QdrantRetriever(client, "duocthu_v1", _FakeEmbedder())
hits = retriever.search_indication("gút cấp", limit=4)
assert [hit.document.drug_id for hit in hits] == ["colchicin"]
assert client.kwargs["collection_name"] == "duocthu_v1"
assert client.kwargs["query"] == [0.1, 0.2, 0.3]
assert client.kwargs["limit"] == 16
def test_find_by_indication_requires_the_whole_phrase_not_a_scattered_match():
""""sốt xuất huyết" (dengue) must not match a chunk that only says "sốt"
— the phrase itself has to appear, not just each of its words somewhere."""