Fingerprint the Qdrant corpus by content, not by count
This commit is contained in:
@@ -0,0 +1,53 @@
|
|||||||
|
name: Audit production Qdrant corpus (read-only)
|
||||||
|
|
||||||
|
# Answers "is the practice corpus the same corpus production serves?" with
|
||||||
|
# content hashes rather than a point count, which two different corpora can
|
||||||
|
# share. The identical script runs against the k3s cluster over SSH, so the
|
||||||
|
# two fingerprints are directly comparable.
|
||||||
|
#
|
||||||
|
# Read-only: it scrolls points and reads collection info. It changes nothing on
|
||||||
|
# production, and its path is not in deploy.yml's filters, so merging it cannot
|
||||||
|
# restart the Compose stack.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: audit-qdrant-corpus
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
audit:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Ship the script rather than inlining it, so production and practice
|
||||||
|
# provably run the same bytes instead of two copies that can drift.
|
||||||
|
- name: Encode fingerprint script
|
||||||
|
run: echo "SCRIPT_B64=$(base64 -w0 scripts/qdrant_fingerprint.py)" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Fingerprint production corpus over SSH
|
||||||
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
|
env:
|
||||||
|
SCRIPT_B64: ${{ env.SCRIPT_B64 }}
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.EC2_HOST }}
|
||||||
|
username: ubuntu
|
||||||
|
key: ${{ secrets.EC2_SSH_KEY }}
|
||||||
|
command_timeout: 30m
|
||||||
|
envs: SCRIPT_B64
|
||||||
|
script: |
|
||||||
|
set -eu
|
||||||
|
cd ~/app/infra/docker
|
||||||
|
ai_id=$(sudo docker compose -f docker-compose.prod.yml ps -q ai-service)
|
||||||
|
test -n "$ai_id"
|
||||||
|
|
||||||
|
printf '%s\n' '=== qdrant_version ==='
|
||||||
|
sudo docker compose -f docker-compose.prod.yml images qdrant
|
||||||
|
|
||||||
|
printf '%s\n' '=== corpus_fingerprint ==='
|
||||||
|
printf '%s' "$SCRIPT_B64" | base64 -d | sudo docker exec -i "$ai_id" python -
|
||||||
@@ -26,12 +26,30 @@ jobs:
|
|||||||
- name: Render default and production manifests
|
- name: Render default and production manifests
|
||||||
run: |
|
run: |
|
||||||
helm template default infra/helm/medical-chatbot > /tmp/default.yaml
|
helm template default infra/helm/medical-chatbot > /tmp/default.yaml
|
||||||
|
|
||||||
|
# values-prod.yaml leaves the image tags empty on purpose: production
|
||||||
|
# must run an immutable commit SHA, supplied per deploy. Rendering it
|
||||||
|
# without one has to fail rather than fall back to a development tag,
|
||||||
|
# so assert that failure here — otherwise the guard could rot into a
|
||||||
|
# silent default and nobody would notice until a cutover.
|
||||||
|
if helm template production infra/helm/medical-chatbot \
|
||||||
|
--values infra/helm/medical-chatbot/values-prod.yaml \
|
||||||
|
> /tmp/untagged.yaml 2>/tmp/untagged.err; then
|
||||||
|
echo "::error::production render succeeded with no image tag; the immutable-tag guard is gone"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
grep -q 'image.tag must be set to an immutable tag' /tmp/untagged.err
|
||||||
|
|
||||||
helm template production infra/helm/medical-chatbot \
|
helm template production infra/helm/medical-chatbot \
|
||||||
--values infra/helm/medical-chatbot/values-prod.yaml \
|
--values infra/helm/medical-chatbot/values-prod.yaml \
|
||||||
|
--set aiService.image.tag="$GITHUB_SHA" \
|
||||||
|
--set web.image.tag="$GITHUB_SHA" \
|
||||||
> /tmp/production.yaml
|
> /tmp/production.yaml
|
||||||
grep -q 'ANSWER_MODEL_ID: "qwen.qwen3-next-80b-a3b"' /tmp/production.yaml
|
grep -q 'ANSWER_MODEL_ID: "qwen.qwen3-next-80b-a3b"' /tmp/production.yaml
|
||||||
grep -q 'RERANK_ENABLED: "true"' /tmp/production.yaml
|
grep -q 'RERANK_ENABLED: "true"' /tmp/production.yaml
|
||||||
grep -q 'checksum/runtime-config:' /tmp/production.yaml
|
grep -q 'checksum/runtime-config:' /tmp/production.yaml
|
||||||
|
grep -q "image: \"ghcr.io/baovu2k4/vsf-duocthu-ai-service:$GITHUB_SHA\"" /tmp/production.yaml
|
||||||
|
grep -q "image: \"ghcr.io/baovu2k4/vsf-duocthu-web:$GITHUB_SHA\"" /tmp/production.yaml
|
||||||
|
|
||||||
# The practice cluster is only evidence for the production migration
|
# The practice cluster is only evidence for the production migration
|
||||||
# while it renders the same behavioural contract as production, so both
|
# while it renders the same behavioural contract as production, so both
|
||||||
@@ -45,36 +63,45 @@ jobs:
|
|||||||
--values infra/helm/medical-chatbot/values-practice-data.yaml \
|
--values infra/helm/medical-chatbot/values-practice-data.yaml \
|
||||||
> /tmp/practice-data.yaml
|
> /tmp/practice-data.yaml
|
||||||
|
|
||||||
|
# A bare `grep -q` fails the step with no indication of which
|
||||||
|
# assertion broke, and `set -e` ignores a status inverted with `!`,
|
||||||
|
# so a `! grep -q` assertion can never fail at all. Both directions
|
||||||
|
# go through helpers that name the pattern and exit explicitly.
|
||||||
|
# `--` matters: a YAML list item pattern starts with `-`, which grep
|
||||||
|
# would otherwise parse as an option bundle.
|
||||||
|
expect() {
|
||||||
|
if ! grep -q -- "$2" "$1"; then
|
||||||
|
echo "::error::$1 is missing: $2"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
refute() {
|
||||||
|
if grep -q -- "$2" "$1"; then
|
||||||
|
echo "::error::$1 must not contain: $2"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# Behavioural parity with the audited production runtime contract.
|
# Behavioural parity with the audited production runtime contract.
|
||||||
grep -q 'ANSWER_MODEL_ID: "qwen.qwen3-next-80b-a3b"' /tmp/practice-app.yaml
|
expect /tmp/practice-app.yaml 'ANSWER_MODEL_ID: "qwen.qwen3-next-80b-a3b"'
|
||||||
grep -q 'ANSWER_PROVIDER: "bedrock-converse"' /tmp/practice-app.yaml
|
expect /tmp/practice-app.yaml 'ANSWER_PROVIDER: "bedrock-converse"'
|
||||||
grep -q 'EMBEDDING_PROVIDER: "cohere-v4"' /tmp/practice-app.yaml
|
expect /tmp/practice-app.yaml 'EMBEDDING_PROVIDER: "cohere-v4"'
|
||||||
grep -q 'EMBEDDING_DIMENSIONS: "1024"' /tmp/practice-app.yaml
|
expect /tmp/practice-app.yaml 'EMBEDDING_DIMENSIONS: "1024"'
|
||||||
grep -q 'EVIDENCE_MINIMUM_SCORE: "0.12"' /tmp/practice-app.yaml
|
expect /tmp/practice-app.yaml 'EVIDENCE_MINIMUM_SCORE: "0.12"'
|
||||||
grep -q 'RERANK_ENABLED: "true"' /tmp/practice-app.yaml
|
expect /tmp/practice-app.yaml 'RERANK_ENABLED: "true"'
|
||||||
grep -q 'AWS_REGION: "us-east-1"' /tmp/practice-app.yaml
|
expect /tmp/practice-app.yaml 'AWS_REGION: "us-east-1"'
|
||||||
grep -q 'checksum/runtime-config:' /tmp/practice-app.yaml
|
expect /tmp/practice-app.yaml 'checksum/runtime-config:'
|
||||||
grep -q 'host: readytochat.realvuxbaro.me' /tmp/practice-app.yaml
|
expect /tmp/practice-app.yaml '- host: "readytochat.realvuxbaro.me"'
|
||||||
|
|
||||||
# The app release must own neither data StatefulSet: PostgreSQL and
|
# The app release must own neither data StatefulSet: PostgreSQL and
|
||||||
# Qdrant belong to the data release, so an app-side sync failure or
|
# Qdrant belong to the data release, so an app-side sync failure or
|
||||||
# prune can never delete the corpus or the query history. Only those
|
# prune can never delete the corpus or the query history. Only those
|
||||||
# two use volumeClaimTemplates — the observability PVCs are the app
|
# two use volumeClaimTemplates — the observability PVCs are the app
|
||||||
# release's own and are expected here.
|
# release's own and are expected here.
|
||||||
#
|
|
||||||
# `set -e` ignores a command whose status is inverted with `!`, so
|
|
||||||
# every must-NOT-contain assertion is written as an explicit exit.
|
|
||||||
refute() {
|
|
||||||
if grep -q "$2" "$1"; then
|
|
||||||
echo "::error::$1 must not contain: $2"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
refute /tmp/practice-app.yaml 'volumeClaimTemplates'
|
refute /tmp/practice-app.yaml 'volumeClaimTemplates'
|
||||||
grep -q 'medical-chatbot-data-medical-chatbot-qdrant' /tmp/practice-app.yaml
|
expect /tmp/practice-app.yaml 'medical-chatbot-data-medical-chatbot-qdrant'
|
||||||
|
|
||||||
# ...and the data release must own nothing else.
|
# ...and the data release must own nothing else.
|
||||||
refute /tmp/practice-data.yaml 'medical-chatbot-data-medical-chatbot-ai-service'
|
refute /tmp/practice-data.yaml 'medical-chatbot-data-medical-chatbot-ai-service'
|
||||||
refute /tmp/practice-data.yaml 'kind: Ingress'
|
refute /tmp/practice-data.yaml 'kind: Ingress'
|
||||||
grep -q 'volumeClaimTemplates' /tmp/practice-data.yaml
|
expect /tmp/practice-data.yaml 'volumeClaimTemplates'
|
||||||
|
|||||||
@@ -213,14 +213,97 @@ the absolute approximately 12-second response time would be a separate RAG
|
|||||||
architecture change because a normal turn currently performs understanding,
|
architecture change because a normal turn currently performs understanding,
|
||||||
generation and entailment as sequential Qwen calls.
|
generation and entailment as sequential Qwen calls.
|
||||||
|
|
||||||
|
## GitOps source-of-truth remediation applied (Claude, 2026-08-17 afternoon)
|
||||||
|
|
||||||
|
Both practice Applications now render from tracked values files instead of
|
||||||
|
untracked inline values. This closes what was open risk 1.
|
||||||
|
|
||||||
|
- `infra/helm/medical-chatbot/values-practice.yaml` and
|
||||||
|
`values-practice-data.yaml` hold every stable non-secret setting of the two
|
||||||
|
releases. Pushed to `master` at `a5d1b86`.
|
||||||
|
- Only the `aiService.image` / `web.image` blocks remain inline on
|
||||||
|
`medical-chatbot-app`, because `.github/scripts/sync_practice_argocd.py`
|
||||||
|
regex-rewrites those tags on every push; a tag committed to Git would be
|
||||||
|
stale by design. The rewrite contract (exactly one `repository:`/`tag:` pair
|
||||||
|
per image) was asserted against the new inline block before the PUT.
|
||||||
|
`medical-chatbot-data` now carries no inline values at all.
|
||||||
|
- Nothing secret was inline, so nothing secret moved. Bedrock still
|
||||||
|
authenticates through the node's instance role.
|
||||||
|
- `helm-chart.yml` now renders both practice releases and asserts the
|
||||||
|
production behavioural contract on them, plus that the app release owns
|
||||||
|
neither data StatefulSet — so an app-side sync or prune cannot delete the
|
||||||
|
corpus or the query history.
|
||||||
|
|
||||||
|
Evidence that the refactor changed nothing:
|
||||||
|
|
||||||
|
- The tracked files were checked to parse to structures identical to the live
|
||||||
|
inline values minus the image blocks, before any Application was edited.
|
||||||
|
- ArgoCD effective manifests were captured before and after. Both releases are
|
||||||
|
**byte-for-byte identical** (23 and 6 manifests). Both converged
|
||||||
|
Synced/Healthy at `a5d1b86`. The unchanged `checksum/runtime-config` means
|
||||||
|
no Pod was rolled.
|
||||||
|
|
||||||
|
Independent interleaved re-measurement, five *different* clinical questions
|
||||||
|
(Codex's controlled sample repeated one Zolpidem query), fresh conversation
|
||||||
|
each, alternating which environment ran first:
|
||||||
|
|
||||||
|
| Scope | Practice | Production |
|
||||||
|
| --- | ---: | ---: |
|
||||||
|
| Average | 12.37 s | 13.06 s |
|
||||||
|
| Median | 11.90 s | 12.77 s |
|
||||||
|
| Maximum | 14.27 s | 16.25 s |
|
||||||
|
| Errors/timeouts | 0 | 0 |
|
||||||
|
|
||||||
|
All ten answers were `answerable` with at least one citation, and all five
|
||||||
|
question pairs agreed on decision. This corroborates the latency-parity verdict
|
||||||
|
across question variety rather than a single repeated query. Answer depth is
|
||||||
|
still not deterministic between environments: Q1 returned 271 characters with
|
||||||
|
one citation on practice against 769 characters with two on production, while
|
||||||
|
Q2 and Q5 were character-identical. Decision parity is established; content
|
||||||
|
parity is not, and needs a golden-set comparison before cutover.
|
||||||
|
|
||||||
|
## Immutable production images applied (Claude, 2026-08-17 afternoon)
|
||||||
|
|
||||||
|
`values-prod.yaml` was not deployable as written, beyond the known `latest`
|
||||||
|
problem: it pinned no `image.repository`, so both images fell back to the
|
||||||
|
chart's local development names (`duocthu-ai-service`, `duocthu-web`) and would
|
||||||
|
never have resolved the GHCR packages, and it carried no `imagePullSecrets` for
|
||||||
|
those private packages.
|
||||||
|
|
||||||
|
Fixed on `master` at `b66c7e5`:
|
||||||
|
|
||||||
|
- Both GHCR repositories pinned, `ghcr-pull-secret` declared.
|
||||||
|
- Both tags left empty on purpose. Production must run an immutable commit SHA
|
||||||
|
supplied per deploy — the same shape practice already uses — because a
|
||||||
|
`latest` tag makes "which commit is production running?" unanswerable and
|
||||||
|
silently breaks rollback when the tag later points at different content.
|
||||||
|
- A new `medical-chatbot.image` helper demands the tag explicitly, so an unset
|
||||||
|
tag fails the render instead of degrading into the chart's `local` default.
|
||||||
|
- CI asserts that failure directly (renders `values-prod.yaml` with no tag and
|
||||||
|
requires a non-zero exit plus the guard's message), so the guard cannot rot
|
||||||
|
into a silent default unnoticed.
|
||||||
|
|
||||||
|
Still open before this file can be used: the production ArgoCD Application does
|
||||||
|
not exist yet, and the `medical-chatbot-prod` Secret it expects
|
||||||
|
(`secret.create: false`) must be created in the target namespace first.
|
||||||
|
|
||||||
|
Verification that neither afternoon change disturbed the running cluster: after
|
||||||
|
both merges, ArgoCD converged Synced/Healthy at `b66c7e5` and the effective
|
||||||
|
manifests of both releases remained **byte-for-byte identical to the original
|
||||||
|
pre-refactor baseline**. A live query returned `answerable` with one citation
|
||||||
|
and character-identical content on both environments (11.36 s practice,
|
||||||
|
11.66 s production).
|
||||||
|
|
||||||
## Migration risks currently open
|
## Migration risks currently open
|
||||||
|
|
||||||
1. The live practice Applications use untracked inline Helm values. This is not
|
1. PostgreSQL/Qdrant snapshot, restore, rollback, ingress/TLS/DNS, secrets,
|
||||||
yet a complete Git source of truth.
|
|
||||||
2. `values-prod.yaml` still uses mutable `latest` image tags; it is not safe for
|
|
||||||
cutover as written even though its behavior settings now match production.
|
|
||||||
3. PostgreSQL/Qdrant snapshot, restore, rollback, ingress/TLS/DNS, secrets,
|
|
||||||
resource limits, and failure recovery still need explicit rehearsal gates.
|
resource limits, and failure recovery still need explicit rehearsal gates.
|
||||||
|
3. Cluster-level inspection is currently blocked from this machine: the
|
||||||
|
practice security group scopes port 6443 to the operator's own outbound IP,
|
||||||
|
which has changed again, so `kubectl` hangs. ArgoCD's API and the public
|
||||||
|
ingress were used instead. Re-open the SG rule before any step that needs
|
||||||
|
direct Pod/exec access.
|
||||||
|
4. Answer-content parity between environments is unverified (see above).
|
||||||
|
|
||||||
## Workspace ownership and current edits
|
## Workspace ownership and current edits
|
||||||
|
|
||||||
@@ -228,23 +311,25 @@ generation and entailment as sequential Qwen calls.
|
|||||||
pre-existing uncommitted Claude/user changes in three Helm files concerning
|
pre-existing uncommitted Claude/user changes in three Helm files concerning
|
||||||
optional AWS static credentials plus untracked slide/material files. Do not
|
optional AWS static credentials plus untracked slide/material files. Do not
|
||||||
stage, overwrite, or discard them accidentally.
|
stage, overwrite, or discard them accidentally.
|
||||||
- Codex created clean worktree `D:\VSF-DUOCTHU-codex-argocd`, branch
|
- Codex worktree `D:\VSF-DUOCTHU-codex-argocd`, branch
|
||||||
`agent/argocd-prod-migration`, from `origin/master` to isolate migration work.
|
`agent/argocd-prod-migration`. Its migration work landed on `master` through
|
||||||
- No Helm migration change has been made in that clean worktree yet at this
|
`5a1a600`..`1684e10`; the worktree is clean with nothing in flight.
|
||||||
checkpoint.
|
- Claude worktree `D:\VSF-DUOCTHU-claude-gitops`, branch
|
||||||
|
`agent/gitops-tracked-values`, holds the GitOps source-of-truth work above.
|
||||||
|
Merged fast-forward into `master` at `a5d1b86`. See
|
||||||
|
`CLAUDE_CLAIM_2026-08-17.md`.
|
||||||
- A stale Claude worktree under `.claude/worktrees/agent-a1f5e73fc2ce814e8`
|
- A stale Claude worktree under `.claude/worktrees/agent-a1f5e73fc2ce814e8`
|
||||||
contains unrelated, uncommitted table-reconstruction work from 2026-08-05.
|
contains unrelated, uncommitted table-reconstruction work from 2026-08-05.
|
||||||
|
|
||||||
## Next safe execution order
|
## Next safe execution order
|
||||||
|
|
||||||
1. Move the practice Application's stable non-secret config out of inline Helm
|
1. Create the production ArgoCD Application against `values-prod.yaml`, and the
|
||||||
values and into a tracked values file; keep only dynamic image tags and
|
`medical-chatbot-prod` Secret it expects, without pointing DNS at it yet.
|
||||||
secret references outside Git.
|
2. Restore the security-group rule for the operator's current IP, then verify
|
||||||
2. Verify the actual running Pod environment, image digest, Qdrant identity,
|
the running Pod environment, image digest, Qdrant identity, health,
|
||||||
health, readiness and traces through a cluster-level inspection path.
|
readiness and traces through a cluster-level inspection path.
|
||||||
3. Run a larger interleaved production/practice benchmark and compare
|
3. Run the golden set against both environments and compare answer content, not
|
||||||
p50/p95/p99,
|
just decision and latency.
|
||||||
timeout rate, answer decisions and citations.
|
|
||||||
4. Rehearse state restore and DNS rollback. Do not repoint `realvuxbaro.me`
|
4. Rehearse state restore and DNS rollback. Do not repoint `realvuxbaro.me`
|
||||||
until the gates and rollback path pass.
|
until the gates and rollback path pass.
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,23 @@
|
|||||||
{{- end -}}
|
{{- end -}}
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Container image reference.
|
||||||
|
|
||||||
|
The tag is demanded explicitly rather than defaulted so that a values file
|
||||||
|
which deliberately leaves it unset fails the render instead of silently
|
||||||
|
inheriting the chart's `local` development tag. Production and practice both
|
||||||
|
supply an immutable commit SHA per deploy — practice through the ArgoCD
|
||||||
|
Application's inline values, rewritten by sync_practice_argocd.py — and a
|
||||||
|
chart that guessed a tag here would deploy something nobody asked for.
|
||||||
|
|
||||||
|
Usage: {{ include "medical-chatbot.image" (dict "image" .Values.web.image "name" "web") }}
|
||||||
|
*/}}
|
||||||
|
{{- define "medical-chatbot.image" -}}
|
||||||
|
{{- $tag := required (printf "%s.image.tag must be set to an immutable tag (a commit SHA); the chart will not guess one" .name) .image.tag -}}
|
||||||
|
{{- printf "%s:%s" (required (printf "%s.image.repository must be set" .name) .image.repository) $tag -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
{{- define "medical-chatbot.labels" -}}
|
{{- define "medical-chatbot.labels" -}}
|
||||||
app.kubernetes.io/name: {{ include "medical-chatbot.name" . }}
|
app.kubernetes.io/name: {{ include "medical-chatbot.name" . }}
|
||||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ spec:
|
|||||||
{{- if .Values.aiService.migration.enabled }}
|
{{- if .Values.aiService.migration.enabled }}
|
||||||
initContainers:
|
initContainers:
|
||||||
- name: migrate
|
- name: migrate
|
||||||
image: "{{ .Values.aiService.image.repository }}:{{ .Values.aiService.image.tag }}"
|
image: {{ include "medical-chatbot.image" (dict "image" .Values.aiService.image "name" "aiService") | quote }}
|
||||||
imagePullPolicy: {{ .Values.aiService.image.pullPolicy }}
|
imagePullPolicy: {{ .Values.aiService.image.pullPolicy }}
|
||||||
command: ["python", "migrate.py"]
|
command: ["python", "migrate.py"]
|
||||||
envFrom:
|
envFrom:
|
||||||
@@ -69,7 +69,7 @@ spec:
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
containers:
|
containers:
|
||||||
- name: ai-service
|
- name: ai-service
|
||||||
image: "{{ .Values.aiService.image.repository }}:{{ .Values.aiService.image.tag }}"
|
image: {{ include "medical-chatbot.image" (dict "image" .Values.aiService.image "name" "aiService") | quote }}
|
||||||
imagePullPolicy: {{ .Values.aiService.image.pullPolicy }}
|
imagePullPolicy: {{ .Values.aiService.image.pullPolicy }}
|
||||||
ports:
|
ports:
|
||||||
- { name: http, containerPort: 8000 }
|
- { name: http, containerPort: 8000 }
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ spec:
|
|||||||
{{- toYaml .Values.global.imagePullSecrets | nindent 8 }}
|
{{- toYaml .Values.global.imagePullSecrets | nindent 8 }}
|
||||||
containers:
|
containers:
|
||||||
- name: web
|
- name: web
|
||||||
image: "{{ .Values.web.image.repository }}:{{ .Values.web.image.tag }}"
|
image: {{ include "medical-chatbot.image" (dict "image" .Values.web.image "name" "web") | quote }}
|
||||||
imagePullPolicy: {{ .Values.web.image.pullPolicy }}
|
imagePullPolicy: {{ .Values.web.image.pullPolicy }}
|
||||||
env:
|
env:
|
||||||
- name: AI_SERVICE_URL
|
- name: AI_SERVICE_URL
|
||||||
|
|||||||
@@ -1,10 +1,33 @@
|
|||||||
|
# Production values for the eventual ArgoCD cutover of realvuxbaro.me.
|
||||||
|
#
|
||||||
|
# Not live yet: production still runs Docker Compose on its own EC2, which is
|
||||||
|
# the DNS-level rollback for the migration. This file is what the production
|
||||||
|
# ArgoCD Application will render from once the rehearsal gates pass.
|
||||||
|
#
|
||||||
|
# The behavioural settings mirror the production runtime contract audited on
|
||||||
|
# 2026-08-17 — see coordination/ARGOCD_PRODUCTION_MIGRATION_STATE_2026-08-17.md.
|
||||||
|
|
||||||
global:
|
global:
|
||||||
environment: production
|
environment: production
|
||||||
|
# The GHCR packages are private, same as on the practice cluster. The Secret
|
||||||
|
# must exist in the target namespace before the first sync.
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: ghcr-pull-secret
|
||||||
|
|
||||||
aiService:
|
aiService:
|
||||||
replicaCount: 2
|
replicaCount: 2
|
||||||
image:
|
image:
|
||||||
tag: latest
|
repository: ghcr.io/baovu2k4/vsf-duocthu-ai-service
|
||||||
|
# Deliberately empty. Production must run an immutable, verifiable image,
|
||||||
|
# so the tag is supplied per deploy as a commit SHA — through the ArgoCD
|
||||||
|
# Application's inline values, exactly as the practice cluster does. A
|
||||||
|
# `latest` here would make "which code is production running?" unanswerable
|
||||||
|
# and would break rollback, since the same tag would point at new content.
|
||||||
|
#
|
||||||
|
# `medical-chatbot.image` turns this empty value into a hard render error
|
||||||
|
# rather than a silent fallback to the chart's `local` development tag.
|
||||||
|
tag: ""
|
||||||
|
pullPolicy: Always
|
||||||
config:
|
config:
|
||||||
embeddingProvider: cohere-v4
|
embeddingProvider: cohere-v4
|
||||||
embeddingDimensions: 1024
|
embeddingDimensions: 1024
|
||||||
@@ -18,7 +41,9 @@ aiService:
|
|||||||
web:
|
web:
|
||||||
replicaCount: 2
|
replicaCount: 2
|
||||||
image:
|
image:
|
||||||
tag: latest
|
repository: ghcr.io/baovu2k4/vsf-duocthu-web
|
||||||
|
tag: ""
|
||||||
|
pullPolicy: Always
|
||||||
|
|
||||||
ingress:
|
ingress:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"""Content fingerprint of a Qdrant collection, for comparing two environments.
|
||||||
|
|
||||||
|
Point count and collection status say nothing about whether two collections
|
||||||
|
hold the *same corpus*: the same 15,100 points could carry different text, be
|
||||||
|
embedded by a different model, or be a stale re-ingest. This walks every point
|
||||||
|
and reduces it to hashes that only match when the content matches.
|
||||||
|
|
||||||
|
Payload and vectors are hashed separately on purpose. If the payload hash
|
||||||
|
matches but the vector hash does not, the same source text was embedded
|
||||||
|
differently — a different embedding model or dimension — which is exactly the
|
||||||
|
failure a migration can introduce silently.
|
||||||
|
|
||||||
|
Per-point digests are sorted before the final hash, so scroll order cannot
|
||||||
|
change the result. Vectors are rounded before hashing because float formatting
|
||||||
|
is not guaranteed identical across versions; 6 decimals is far finer than any
|
||||||
|
meaningful embedding difference.
|
||||||
|
|
||||||
|
Reads QDRANT_URL and QDRANT_COLLECTION from the environment, so it runs
|
||||||
|
unchanged inside the Compose ai-service container and the k3s ai-service pod.
|
||||||
|
Read-only: it issues nothing but scroll and collection-info requests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
BATCH = 256
|
||||||
|
VECTOR_PRECISION = 6
|
||||||
|
|
||||||
|
|
||||||
|
def post(url: str, body: dict) -> dict:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=json.dumps(body).encode(),
|
||||||
|
method="POST",
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||||
|
return json.load(resp)
|
||||||
|
|
||||||
|
|
||||||
|
def get(url: str) -> dict:
|
||||||
|
with urllib.request.urlopen(url, timeout=60) as resp:
|
||||||
|
return json.load(resp)
|
||||||
|
|
||||||
|
|
||||||
|
def canonical(value) -> str:
|
||||||
|
return json.dumps(value, sort_keys=True, ensure_ascii=True, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
base = os.environ["QDRANT_URL"].rstrip("/")
|
||||||
|
collection = os.environ.get("QDRANT_COLLECTION", "duocthu_v1")
|
||||||
|
|
||||||
|
info = get(f"{base}/collections/{collection}")["result"]
|
||||||
|
vectors_config = info.get("config", {}).get("params", {}).get("vectors", {})
|
||||||
|
|
||||||
|
payload_digests: list[str] = []
|
||||||
|
vector_digests: list[str] = []
|
||||||
|
drug_ids: set[str] = set()
|
||||||
|
section_keys: set[str] = set()
|
||||||
|
pages: list[int] = []
|
||||||
|
missing_vectors = 0
|
||||||
|
|
||||||
|
offset = None
|
||||||
|
seen = 0
|
||||||
|
while True:
|
||||||
|
body = {"limit": BATCH, "with_payload": True, "with_vector": True}
|
||||||
|
if offset is not None:
|
||||||
|
body["offset"] = offset
|
||||||
|
result = post(f"{base}/collections/{collection}/points/scroll", body)["result"]
|
||||||
|
points = result.get("points", [])
|
||||||
|
if not points:
|
||||||
|
break
|
||||||
|
|
||||||
|
for point in points:
|
||||||
|
pid = str(point.get("id"))
|
||||||
|
payload = point.get("payload") or {}
|
||||||
|
payload_digests.append(
|
||||||
|
hashlib.sha256((pid + "|" + canonical(payload)).encode()).hexdigest()
|
||||||
|
)
|
||||||
|
|
||||||
|
vector = point.get("vector")
|
||||||
|
if isinstance(vector, dict): # named vectors
|
||||||
|
vector = canonical(
|
||||||
|
{k: [round(float(x), VECTOR_PRECISION) for x in v] for k, v in vector.items()}
|
||||||
|
)
|
||||||
|
elif isinstance(vector, list):
|
||||||
|
vector = canonical([round(float(x), VECTOR_PRECISION) for x in vector])
|
||||||
|
else:
|
||||||
|
missing_vectors += 1
|
||||||
|
vector = "null"
|
||||||
|
vector_digests.append(hashlib.sha256((pid + "|" + vector).encode()).hexdigest())
|
||||||
|
|
||||||
|
if payload.get("drug_id"):
|
||||||
|
drug_ids.add(str(payload["drug_id"]))
|
||||||
|
if payload.get("section_key"):
|
||||||
|
section_keys.add(str(payload["section_key"]))
|
||||||
|
for key in ("printed_page_start", "printed_page_end"):
|
||||||
|
value = payload.get(key)
|
||||||
|
if isinstance(value, int):
|
||||||
|
pages.append(value)
|
||||||
|
|
||||||
|
seen += len(points)
|
||||||
|
print(f"...scrolled {seen}", file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
offset = result.get("next_page_offset")
|
||||||
|
if offset is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
payload_digests.sort()
|
||||||
|
vector_digests.sort()
|
||||||
|
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"collection": collection,
|
||||||
|
"points_scrolled": seen,
|
||||||
|
"points_count_reported": info.get("points_count"),
|
||||||
|
"status": info.get("status"),
|
||||||
|
"vectors_config": vectors_config,
|
||||||
|
"payload_hash": hashlib.sha256("".join(payload_digests).encode()).hexdigest(),
|
||||||
|
"vector_hash": hashlib.sha256("".join(vector_digests).encode()).hexdigest(),
|
||||||
|
"missing_vectors": missing_vectors,
|
||||||
|
"distinct_drug_ids": len(drug_ids),
|
||||||
|
"distinct_section_keys": len(section_keys),
|
||||||
|
"printed_page_min": min(pages) if pages else None,
|
||||||
|
"printed_page_max": max(pages) if pages else None,
|
||||||
|
},
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user