Fix migration workflow: upload as artifact instead of scp to practice EC2
This commit is contained in:
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -53,6 +53,25 @@ const RULES: Array<{ prefix: string; rules: Rule[] }> = [
|
||||
prefix: "/api/suggest",
|
||||
rules: [{ windowMs: 60_000, max: 120 }],
|
||||
},
|
||||
// `/api/pdf` streams the whole 37MB source PDF from disk on every request,
|
||||
// with no range support and no caching headers. It is unauthenticated, so
|
||||
// repeated fetches are a bandwidth and memory cost on a single small EC2
|
||||
// host. Sized against real UI behaviour rather than guessed: `tra-cuu`
|
||||
// uses it as an iframe `src` whose `#page=` fragment changes per citation
|
||||
// click, and `CitationCard` links to it, so a clinician working through a
|
||||
// long evidence list can legitimately fetch it repeatedly. 30/min is far
|
||||
// above that and still bounds an automated puller.
|
||||
{
|
||||
prefix: "/api/pdf",
|
||||
rules: [{ windowMs: 60_000, max: 30 }],
|
||||
},
|
||||
// `/api/feedback` writes one row per answer (upsert keyed on trace_id), so
|
||||
// the ceiling only needs to exceed how fast a human can rate answers. It
|
||||
// reaches PostgreSQL on every call, which is why it is bounded at all.
|
||||
{
|
||||
prefix: "/api/feedback",
|
||||
rules: [{ windowMs: 60_000, max: 60 }],
|
||||
},
|
||||
];
|
||||
|
||||
const buckets = new Map<string, Bucket>();
|
||||
|
||||
Reference in New Issue
Block a user