From 8459bb1585fadf8e55ee6311296fc3f7b1a41885 Mon Sep 17 00:00:00 2001 From: Xavier Demaret Date: Tue, 7 Jul 2026 09:26:38 +0000 Subject: [PATCH 1/2] docs-dstny-document-intake-prd --- .../dstny-document-intake/ACCEPTANCE_TESTS.md | 128 +++++++ .../IMPLEMENTATION_PLAN.md | 195 ++++++++++ docs/dstny-document-intake/PRD.md | 260 ++++++++++++++ docs/dstny-document-intake/README.md | 48 +++ .../dstny-document-intake/TECHNICAL_DESIGN.md | 336 ++++++++++++++++++ 5 files changed, 967 insertions(+) create mode 100644 docs/dstny-document-intake/ACCEPTANCE_TESTS.md create mode 100644 docs/dstny-document-intake/IMPLEMENTATION_PLAN.md create mode 100644 docs/dstny-document-intake/PRD.md create mode 100644 docs/dstny-document-intake/README.md create mode 100644 docs/dstny-document-intake/TECHNICAL_DESIGN.md diff --git a/docs/dstny-document-intake/ACCEPTANCE_TESTS.md b/docs/dstny-document-intake/ACCEPTANCE_TESTS.md new file mode 100644 index 0000000000..bba58e2731 --- /dev/null +++ b/docs/dstny-document-intake/ACCEPTANCE_TESTS.md @@ -0,0 +1,128 @@ +# Tests d'acceptation - Documents Dstny + +## Test 1 - Navigation + +Etant donne Hermes Workspace ouvert, +quand Xavier regarde la section Knowledge, +alors il voit une entree `Documents Dstny`. + +Attendu: + +- l'entree ne remplace pas `Fichiers`; +- elle ouvre `/documents-dstny`; +- la page charge sans erreur. + +## Test 2 - Upload PDF + +Etant donne un PDF local, +quand Xavier l'upload dans `Documents Dstny`, +alors le document est stocke hors git et apparait dans la liste. + +Attendu: + +- record cree; +- checksum calcule; +- statut `uploaded`; +- chemin sous `$HERMES_HOME/documents/dstny-inbox`; +- pas de fichier cree dans le repo. + +## Test 3 - Metadata obligatoire + +Etant donne un fichier selectionne, +quand Xavier tente de l'enregistrer sans collection ou titre, +alors l'UI bloque l'action. + +Champs obligatoires: + +- titre; +- collection; +- canal; +- type document; +- statut; +- confiance. + +## Test 4 - Ingestion RAG + +Etant donne un document upload avec metadata complete, +quand Xavier clique `Indexer dans le RAG`, +alors Workspace appelle le wrapper RAG et met a jour le registre. + +Attendu: + +- statut passe a `ingesting`, puis `indexed`; +- `ragDocId` renseigne si le wrapper le retourne; +- tags metier transmis; +- erreur visible si le wrapper echoue. + +## Test 5 - Prompt Synthese Flash + +Etant donne un document indexe, +quand Xavier clique `Preparer Synthese Flash`, +alors Workspace affiche un prompt pret a copier. + +Attendu: + +- le prompt contient titre, collection, produit, canal; +- il demande les sources; +- il demande fait/hypothese/interpretation/recommandation; +- il utilise le format Synthese Flash Dstny. + +## Test 6 - Fichier interdit + +Etant donne un fichier `.exe`, +quand Xavier tente de l'uploader, +alors l'API refuse. + +Attendu: + +- HTTP 400; +- message clair; +- aucun fichier stocke. + +## Test 7 - Auth + +Etant donne une requete non authentifiee, +quand elle appelle une route `/api/dstny-documents/*`, +alors l'API retourne 401. + +## Test 8 - Archive sans suppression + +Etant donne un document indexe, +quand Xavier le marque `archive`, +alors il disparait des vues actives mais reste dans le registre. + +Attendu: + +- pas de suppression physique lot 1; +- statut visible dans filtre archive; +- source toujours traçable. + +## Test 9 - RAG indisponible + +Etant donne le wrapper RAG indisponible, +quand Xavier clique `Indexer`, +alors Workspace garde le document et affiche une erreur. + +Attendu: + +- statut `error`; +- `lastError` renseigne; +- aucune perte metadata; +- bouton reessayer disponible. + +## Test 10 - Smoke production + +Sur le VPS: + +```bash +systemctl is-active hermes.service hermes-workspace.service +corepack pnpm test +corepack pnpm build +``` + +Attendu: + +- services actifs; +- tests OK; +- build OK; +- pas d'erreur recente liee a `dstny-documents`. diff --git a/docs/dstny-document-intake/IMPLEMENTATION_PLAN.md b/docs/dstny-document-intake/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000000..0daccb9983 --- /dev/null +++ b/docs/dstny-document-intake/IMPLEMENTATION_PLAN.md @@ -0,0 +1,195 @@ +# Plan d'implementation - Documents Dstny + +## Principes + +- Livraison par petits lots. +- Aucun changement destructif. +- Pas de worker autonome. +- Pas d'action externe. +- Tests a chaque lot. +- Rollback simple. + +## Lot 0 - Cadrage + +Livrables: + +- PRD; +- design technique; +- plan d'implementation; +- tests d'acceptation. + +Critere de sortie: + +- PR draft ouverte; +- scope valide. + +## Lot 1 - Backend registre et modeles + +Fichiers probables: + +- `src/server/dstny-documents.ts` +- `src/server/dstny-documents.test.ts` + +Taches: + +1. definir les types; +2. resoudre les chemins depuis `HERMES_HOME`; +3. creer le registre JSONL append-only; +4. ajouter lecture collapse latest-wins; +5. ajouter filtres simples. + +Tests: + +- creation record; +- update record; +- collapse par id; +- filtres collection/product/status/q. + +## Lot 2 - API list/update + +Fichiers probables: + +- `src/routes/api/dstny-documents/list.ts` +- `src/routes/api/dstny-documents/update.ts` + +Taches: + +1. route list avec auth; +2. route update avec auth; +3. validation Zod; +4. erreurs JSON propres. + +Tests: + +- 401 sans auth; +- list OK; +- update metadata OK; +- path immutable. + +## Lot 3 - Upload fichier + +Fichiers probables: + +- `src/routes/api/dstny-documents/upload.ts` +- tests API upload. + +Taches: + +1. multipart upload; +2. allowlist extensions; +3. taille max; +4. checksum SHA-256; +5. stockage sous `$HERMES_HOME/documents/dstny-inbox///`; +6. record `uploaded`. + +Tests: + +- PDF valide; +- extension interdite; +- path traversal impossible; +- collision nom fichier. + +## Lot 4 - Ingestion RAG + +Fichiers probables: + +- `src/routes/api/dstny-documents/ingest.ts` +- helper commande RAG. + +Taches: + +1. appeler `/home/node/.hermes/bin/dstny-rag-ingest-file`; +2. utiliser `execFile`, jamais shell; +3. parser JSON de sortie; +4. stocker `ragDocId`; +5. gerer erreurs/timeout. + +Tests: + +- wrapper mock OK; +- wrapper mock erreur; +- metadata transformees en tags; +- statut `indexed` ou `error`. + +## Lot 5 - UI Documents Dstny + +Fichiers probables: + +- `src/routes/documents-dstny.tsx` +- `src/screens/documents-dstny/documents-dstny-screen.tsx` +- composants enfants. + +Taches: + +1. page route; +2. table documents; +3. formulaire metadata; +4. upload; +5. action ingest; +6. action prompt. + +Tests: + +- rendu page; +- champs obligatoires; +- upload mock; +- filtre; +- prompt visible. + +## Lot 6 - Navigation + +Taches: + +1. ajouter entree `Documents Dstny` dans Knowledge; +2. icone coherente; +3. libelle court; +4. aucun retrait de `Fichiers`. + +Test: + +- nav contient `Documents Dstny`. + +## Lot 7 - Prompt analyse + +Taches: + +1. endpoint prompt; +2. template Synthese Flash; +3. bouton copier; +4. option ouvrir chat si API existante propre. + +Lot 1 UI: copie seulement. +Lot suivant: ouverture session chat. + +## Lot 8 - Hardening + +Taches: + +1. logs propres; +2. limites taille; +3. messages erreurs; +4. documentation utilisateur; +5. smoke test production. + +## Definition of Done globale + +- `pnpm test` cible OK; +- `pnpm build` OK; +- page accessible; +- upload PDF OK; +- ingestion RAG OK sur VPS; +- document retrouve dans liste; +- prompt Synthese Flash genere; +- aucun secret affiche; +- aucun fichier stocke dans git; +- rollback documente. + +## Branching recommande + +```text +codex/dstny-document-intake-prd +codex/dstny-document-intake-backend +codex/dstny-document-intake-ui +``` + +La PR de cadrage ne doit contenir que `docs/dstny-document-intake/*`. diff --git a/docs/dstny-document-intake/PRD.md b/docs/dstny-document-intake/PRD.md new file mode 100644 index 0000000000..f34a1a95d4 --- /dev/null +++ b/docs/dstny-document-intake/PRD.md @@ -0,0 +1,260 @@ +# PRD - Documents Dstny dans Hermes Workspace + +## 1. Resume + +Hermes Workspace permet deja de naviguer dans les fichiers et Hermes dispose deja d'un RAG Dstny local. Le manque principal est l'etape metier entre les deux: deposer un document, le qualifier avec des metadonnees Dstny, l'indexer dans le RAG, puis declencher une analyse sourcee. + +Le produit a creer est une page native `Documents Dstny`, accessible depuis le menu Knowledge, qui transforme l'upload de fichiers en flux documentaire PMM fiable. + +## 2. Probleme + +Aujourd'hui: + +- le chat n'accepte pas toujours tous les types de fichiers; +- l'explorateur `Fichiers` est brut et non metier; +- l'ingestion RAG existe mais se fait par commande serveur; +- les metadonnees Dstny sont faciles a oublier; +- les documents importants risquent d'etre analyses une fois puis perdus dans le contexte; +- Cassian Hub contient une surcouche documentaire, mais trop large et trop lourde pour ce besoin. + +## 3. Utilisateur cible + +Utilisateur principal: Xavier DEMARET, Responsable Produits Marketing / PMM solo chez Dstny France. + +Cas d'usage: + +- catalogues tarifaires; +- fiches produits; +- supports commerciaux; +- documents fournisseurs; +- contrats; +- exports BI; +- benchmarks concurrence; +- livrables valides; +- documents PDF Engine / GitHub / projets techniques. + +## 4. Objectifs produit + +1. Permettre l'upload de documents metier dans Workspace. +2. Forcer une qualification minimale avant ingestion RAG. +3. Indexer dans le RAG local existant sans changer l'infrastructure RAG. +4. Produire un prompt d'analyse PMM pret a envoyer a Hermes. +5. Conserver un registre des documents, statuts et erreurs. +6. Separar les sources internes, concurrence, personnel et livrables valides. +7. Reduire la charge mentale: Xavier ne doit pas retenir les commandes CLI. + +## 5. Non-objectifs + +- Ne pas remplacer `Fichiers`. +- Ne pas remplacer le RAG existant. +- Ne pas importer Cassian Hub. +- Ne pas creer un dashboard analytique lourd. +- Ne pas ingester automatiquement tout le workspace. +- Ne pas connecter Google Drive/Gmail/Calendar dans ce lot. +- Ne pas envoyer de document ou d'email a l'exterieur. + +## 6. Navigation + +Ajouter une entree dans la section Knowledge: + +```text +KNOWLEDGE + Memoire + Documents Dstny + Competences + MCP + Profils +``` + +Raison: `Fichiers` reste un explorateur brut. `Documents Dstny` devient le flux documentaire metier. + +## 7. Workflow principal + +### 7.1 Upload + +L'utilisateur ouvre `Documents Dstny`, clique `Ajouter un document`, selectionne un fichier. + +Formats acceptes au premier lot: + +- PDF; +- DOCX; +- PPTX; +- XLSX; +- CSV; +- TXT; +- MD. + +### 7.2 Qualification + +Le formulaire demande: + +- titre; +- collection; +- produit; +- canal; +- type de document; +- statut; +- niveau de confiance; +- date du document si connue; +- fournisseur si applicable; +- mots-cles; +- resume court optionnel. + +### 7.3 Ingestion RAG + +L'utilisateur clique `Indexer dans le RAG`. + +Workspace appelle le wrapper existant: + +```bash +/home/node/.hermes/bin/dstny-rag-ingest-file +``` + +L'UI affiche: + +- en attente; +- ingestion en cours; +- indexe; +- erreur. + +### 7.4 Analyse + +L'utilisateur clique `Preparer Synthese Flash`. + +Workspace genere un prompt: + +```text +Synthese Flash : analyse le document "[titre]" depuis le RAG. +Collection : [collection] +Produit : [produit] +Canal : [canal] + +Je veux : +- resume en 5 lignes; +- impacts business; +- impacts techniques; +- impacts pricing si presents; +- risques; +- questions a poser; +- actions recommandees; +- sources citees. + +Distingue fait valide, hypothese, interpretation, recommandation et decision a confirmer. +``` + +Lot 1: prompt copiable. +Lot 2: bouton qui ouvre une nouvelle session chat avec ce prompt. + +## 8. Collections Dstny + +Valeurs recommandees: + +- `dstny_catalogues` +- `dstny_produits` +- `metacentrex_alianza` +- `concurrence` +- `sales_enablement` +- `pricing` +- `mydstny_si` +- `github_pdfengine` +- `livrables_valides` +- `decisions` + +## 9. Champs metier + +### 9.1 Canal + +- `direct` +- `ambassadeur` +- `operateur` +- `interne` +- `tous` + +### 9.2 Type de document + +- `catalogue` +- `fiche_produit` +- `contrat` +- `slide` +- `email` +- `export_bi` +- `pricing` +- `procedure` +- `benchmark` +- `livrable` +- `decision` +- `autre` + +### 9.3 Statut + +- `brouillon` +- `actif` +- `valide` +- `obsolete` +- `archive` + +### 9.4 Confiance + +- `faible` +- `moyen` +- `fort` + +## 10. Etats UI + +Chaque document affiche: + +- titre; +- type; +- produit; +- canal; +- collection; +- statut; +- confiance; +- date upload; +- date ingestion; +- etat ingestion; +- dernier message d'erreur; +- chemin fichier; +- doc_id RAG si disponible. + +## 11. Securite + +- Auth Workspace obligatoire. +- Aucune ingestion sans action explicite. +- Taille max configurable. +- Extensions autorisees uniquement. +- Fichiers stockes hors git. +- Pas d'affichage de secrets. +- Pas d'appel externe. +- Pas de suppression definitive en lot 1. +- Suppression: statut `archive` ou `obsolete`, pas `rm`. + +## 12. Critere de succes + +Le module est accepte si Xavier peut: + +1. uploader un PDF; +2. renseigner ses metadonnees; +3. l'indexer dans le RAG; +4. retrouver le document dans la liste; +5. generer un prompt Synthese Flash; +6. obtenir une reponse Hermes qui cite la source RAG. + +## 13. Risques + +| Risque | Mitigation | +| --- | --- | +| Scope trop large | livrer un module intake minimal | +| Metadata oubliees | formulaire avec champs obligatoires | +| Confusion avec Fichiers | nommer clairement `Documents Dstny` | +| RAG indisponible | etat `erreur` + message explicite | +| Documents obsoletes | statut visible + filtres | +| Fuite de donnees | stockage local, auth, pas d'envoi externe | + +## 14. Decisions ouvertes + +1. Nom final: `Documents Dstny` ou `Bibliotheque Dstny`. +2. Base registre: JSONL ou SQLite. +3. Limite taille fichier par defaut. +4. Bouton analyse: prompt copiable en lot 1 ou ouverture chat immediate. +5. Niveau d'integration avec la page `Fichiers`. diff --git a/docs/dstny-document-intake/README.md b/docs/dstny-document-intake/README.md new file mode 100644 index 0000000000..eeec7d8bfb --- /dev/null +++ b/docs/dstny-document-intake/README.md @@ -0,0 +1,48 @@ +# Dstny Document Intake - cadrage + +Statut: draft PRD +Date: 2026-07-07 +Repository cible: outsourc-e/hermes-workspace + +## Objectif + +Ajouter dans Hermes Workspace une entree native `Documents Dstny` pour combler le manque entre: + +- upload de fichier brut; +- metadata metier Dstny; +- ingestion RAG locale; +- analyse sourcee par Hermes. + +Le module doit rester une extension legere de Workspace. Il ne doit pas embarquer Cassian Hub ni ajouter une deuxieme application. + +## Documents + +| Fichier | Role | +| --- | --- | +| PRD.md | besoin produit, utilisateurs, workflows, exigences | +| TECHNICAL_DESIGN.md | architecture, API, stockage, securite, integration RAG | +| IMPLEMENTATION_PLAN.md | lots de livraison, ordre d'execution, rollback | +| ACCEPTANCE_TESTS.md | tests fonctionnels, securite, non-regression | + +## Decision de cadrage + +On garde: + +- Hermes Workspace comme interface principale; +- le RAG Cassian/Dstny existant; +- le wrapper `/home/node/.hermes/bin/dstny-rag-ingest-file`; +- les skills Dstny PMM deja installes. + +On ajoute: + +- une page `Documents Dstny` dans la section Knowledge; +- un stockage documentaire dedie; +- une base de suivi metadata; +- des routes API pour upload, liste, ingestion et prompt d'analyse. + +On exclut: + +- migration de Cassian Hub; +- remplacement du RAG existant; +- automatisation d'envoi externe; +- ingestion automatique sans validation metadata. diff --git a/docs/dstny-document-intake/TECHNICAL_DESIGN.md b/docs/dstny-document-intake/TECHNICAL_DESIGN.md new file mode 100644 index 0000000000..494e992944 --- /dev/null +++ b/docs/dstny-document-intake/TECHNICAL_DESIGN.md @@ -0,0 +1,336 @@ +# Technical Design - Documents Dstny + +## 1. Architecture cible + +```text +Hermes Workspace UI + src/routes/documents-dstny.tsx + src/screens/documents-dstny/* + +Workspace API + /api/dstny-documents/list + /api/dstny-documents/upload + /api/dstny-documents/update + /api/dstny-documents/ingest + /api/dstny-documents/analyze-prompt + +Storage local + /home/node/.hermes/documents/dstny-inbox/ + /home/node/.hermes/documents/dstny-documents.jsonl + +RAG existant + /home/node/.hermes/bin/dstny-rag-ingest-file + /home/node/.hermes/bin/cassian-rag.py +``` + +## 2. Emplacements + +Par defaut: + +```text +$HERMES_HOME/documents/dstny-inbox +$HERMES_HOME/documents/dstny-documents.jsonl +``` + +Avec `HERMES_HOME=/home/node/.hermes`, cela donne: + +```text +/home/node/.hermes/documents/dstny-inbox +/home/node/.hermes/documents/dstny-documents.jsonl +``` + +Variables optionnelles: + +```text +DSTNY_DOCUMENT_ROOT=/home/node/.hermes/documents/dstny-inbox +DSTNY_DOCUMENT_REGISTRY=/home/node/.hermes/documents/dstny-documents.jsonl +DSTNY_RAG_INGEST_BIN=/home/node/.hermes/bin/dstny-rag-ingest-file +``` + +## 3. Modele de donnees + +```ts +type DstnyDocumentStatus = 'uploaded' | 'ready' | 'ingesting' | 'indexed' | 'error' | 'archived' + +type DstnyDocumentRecord = { + id: string + title: string + originalName: string + storedName: string + filePath: string + mimeType: string + sizeBytes: number + checksumSha256: string + + collection: string + product: string | null + channel: 'direct' | 'ambassadeur' | 'operateur' | 'interne' | 'tous' + docType: string + businessStatus: 'brouillon' | 'actif' | 'valide' | 'obsolete' | 'archive' + confidence: 'faible' | 'moyen' | 'fort' + documentDate: string | null + supplier: string | null + owner: string | null + version: string | null + summary: string | null + keywords: Array + + ingestionStatus: DstnyDocumentStatus + ragDocId: string | null + ragCollection: string | null + lastError: string | null + + uploadedAt: string + updatedAt: string + ingestedAt: string | null +} +``` + +## 4. Registre + +Lot 1: JSONL append-only. + +Fichier: + +```text +$HERMES_HOME/documents/dstny-documents.jsonl +``` + +Chaque mise a jour ajoute un record complet avec le meme `id`. La lecture collapse en memoire sur le dernier record par `id`. + +Raison: + +- simple; +- auditable; +- proche de la doctrine Cassian Hub; +- rollback facile; +- pas de migration DB pour le premier lot. + +Migration possible lot 2: SQLite si besoin de filtres lourds. + +## 5. Upload + +Endpoint: + +```text +POST /api/dstny-documents/upload +``` + +Requete multipart: + +- `file`; +- metadata JSON. + +Regles: + +- auth Workspace obligatoire; +- extension allowlist; +- nom fichier normalise; +- stockage sous un dossier date/collection; +- checksum SHA-256; +- jamais sous le repo git; +- collision resolue par suffixe court. + +Extensions lot 1: + +```text +.pdf .docx .pptx .xlsx .csv .txt .md +``` + +## 6. Liste + +Endpoint: + +```text +GET /api/dstny-documents/list?collection=&product=&status=&q= +``` + +Retour: + +```json +{ + "ok": true, + "documents": [] +} +``` + +## 7. Mise a jour metadata + +Endpoint: + +```text +POST /api/dstny-documents/update +``` + +Permet: + +- corriger titre; +- changer statut; +- changer confiance; +- marquer obsolete/archive; +- enrichir resume/mots-cles. + +Ne permet pas: + +- changer `filePath`; +- supprimer physiquement le fichier; +- modifier le contenu source. + +## 8. Ingestion RAG + +Endpoint: + +```text +POST /api/dstny-documents/ingest +``` + +Payload: + +```json +{ "id": "doc_..." } +``` + +Commande executee: + +```bash +/home/node/.hermes/bin/dstny-rag-ingest-file "$filePath" \ + --collection "$collection" \ + --title "$title" \ + --product "$product" \ + --channel "$channel" \ + --doc-type "$docType" \ + --status "$businessStatus" \ + --confidence "$confidence" +``` + +Tags supplementaires: + +- `source:workspace-upload` +- `document_id:` +- `type:` +- `statut:` +- `canal:` +- `confiance:` + +Parsing de sortie: + +- recuperer `doc_id` JSON si present; +- stocker dans `ragDocId`; +- en cas d'erreur, stocker `lastError`. + +## 9. Prompt d'analyse + +Endpoint: + +```text +POST /api/dstny-documents/analyze-prompt +``` + +Retour: + +```json +{ + "ok": true, + "prompt": "..." +} +``` + +Lot 1: afficher/copier. +Lot 2: ouvrir chat avec prompt pre-rempli. + +## 10. UI + +Route: + +```text +/documents-dstny +``` + +Menu: + +```text +Knowledge > Documents Dstny +``` + +Composants: + +- `documents-dstny-screen.tsx` +- `document-upload-panel.tsx` +- `document-metadata-form.tsx` +- `document-table.tsx` +- `document-detail-panel.tsx` +- `document-ingestion-status.tsx` + +UX: + +- colonne gauche: upload + filtres; +- zone principale: table documents; +- panneau detail: metadata, actions, prompt. + +## 11. Integration navigation + +Modifier le composant de navigation existant pour ajouter: + +```text +Documents Dstny +``` + +dans Knowledge. + +Ne pas supprimer `Fichiers`. + +## 12. Securite + +- utiliser `isAuthenticated(request)` sur chaque route; +- pas d'upload sans auth; +- pas de path traversal; +- resolution `realpath` sous racine autorisee; +- `execFile` avec arguments tableau, jamais shell string; +- timeout ingestion; +- taille max configurable; +- ne jamais retourner de secret ou contenu complet par defaut; +- logs sans token ni chemin sensible inutile. + +## 13. Tests + +Unitaires: + +- validation metadata; +- normalisation fichier; +- registry append/collapse; +- generation commande RAG; +- prompt d'analyse. + +API: + +- unauthorized = 401; +- upload extension interdite = 400; +- upload valide = record cree; +- ingest appelle wrapper mocke; +- erreur wrapper = statut error; +- list filtre correctement. + +UI: + +- page charge; +- formulaire exige champs obligatoires; +- upload affiche document; +- bouton ingest change statut; +- bouton prompt affiche prompt. + +## 14. Rollback + +Rollback code: + +- revert PR. + +Rollback donnees: + +- les fichiers restent sous `$HERMES_HOME/documents`; +- le registre JSONL reste lisible manuellement; +- aucune suppression automatique. + +Rollback ingestion: + +- ne pas supprimer Qdrant automatiquement en lot 1; +- marquer le document `obsolete` si ingestion a ete faite par erreur. From b554f0b1d2ac15c5f48df06de776cbbb475448bb Mon Sep 17 00:00:00 2001 From: Xavier Demaret Date: Tue, 7 Jul 2026 10:21:57 +0000 Subject: [PATCH 2/2] feat: add dstny document registry backend --- src/server/dstny-documents.test.ts | 162 ++++++++++++++ src/server/dstny-documents.ts | 345 +++++++++++++++++++++++++++++ 2 files changed, 507 insertions(+) create mode 100644 src/server/dstny-documents.test.ts create mode 100644 src/server/dstny-documents.ts diff --git a/src/server/dstny-documents.test.ts b/src/server/dstny-documents.test.ts new file mode 100644 index 0000000000..d6afcd74d8 --- /dev/null +++ b/src/server/dstny-documents.test.ts @@ -0,0 +1,162 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +let tempHome: string +let originalHermesHome: string | undefined +let originalClaudeHome: string | undefined +let originalDocumentRoot: string | undefined +let originalDocumentRegistry: string | undefined + +async function loadModule() { + vi.resetModules() + return await import('./dstny-documents') +} + +beforeEach(() => { + tempHome = mkdtempSync(join(tmpdir(), 'dstny-documents-test-')) + originalHermesHome = process.env.HERMES_HOME + originalClaudeHome = process.env.CLAUDE_HOME + originalDocumentRoot = process.env.DSTNY_DOCUMENT_ROOT + originalDocumentRegistry = process.env.DSTNY_DOCUMENT_REGISTRY + process.env.HERMES_HOME = tempHome + delete process.env.CLAUDE_HOME + delete process.env.DSTNY_DOCUMENT_ROOT + delete process.env.DSTNY_DOCUMENT_REGISTRY +}) + +afterEach(() => { + rmSync(tempHome, { recursive: true, force: true }) + if (originalHermesHome === undefined) delete process.env.HERMES_HOME + else process.env.HERMES_HOME = originalHermesHome + if (originalClaudeHome === undefined) delete process.env.CLAUDE_HOME + else process.env.CLAUDE_HOME = originalClaudeHome + if (originalDocumentRoot === undefined) delete process.env.DSTNY_DOCUMENT_ROOT + else process.env.DSTNY_DOCUMENT_ROOT = originalDocumentRoot + if (originalDocumentRegistry === undefined) delete process.env.DSTNY_DOCUMENT_REGISTRY + else process.env.DSTNY_DOCUMENT_REGISTRY = originalDocumentRegistry + vi.resetModules() +}) + +function writeSampleFile(name = 'sample.pdf') { + const filePath = join(tempHome, name) + writeFileSync(filePath, 'PDF-ish content for Dstny') + return filePath +} + +describe('dstny-documents registry', () => { + it('creates an append-only document record with required metadata', async () => { + const mod = await loadModule() + const filePath = writeSampleFile('Meta Centrex offer.pdf') + const doc = mod.createDstnyDocumentRecord({ + title: 'MetaCentrex offer', + filePath, + collection: 'metacentrex_alianza', + product: 'metacentrex', + channel: 'operateur', + docType: 'contrat', + businessStatus: 'actif', + confidence: 'moyen', + keywords: 'metacentrex, alianza, metacentrex', + }) + + expect(doc.id).toMatch(/^doc_[a-f0-9]{16}$/) + expect(doc.storedName).toBe('Meta-Centrex-offer.pdf') + expect(doc.sizeBytes).toBeGreaterThan(0) + expect(doc.checksumSha256).toHaveLength(64) + expect(doc.keywords).toEqual(['metacentrex', 'alianza']) + expect(mod.getDstnyDocumentRegistryPath()).toBe(join(tempHome, 'documents', 'dstny-documents.jsonl')) + }) + + it('collapses later updates by id while preserving immutable file fields', async () => { + const mod = await loadModule() + const filePath = writeSampleFile() + const created = mod.createDstnyDocumentRecord({ + title: 'Original title', + filePath, + collection: 'dstny_catalogues', + channel: 'direct', + docType: 'catalogue', + businessStatus: 'brouillon', + confidence: 'faible', + }) + + const updated = mod.updateDstnyDocumentRecord(created.id, { + title: 'Updated title', + businessStatus: 'valide', + ingestionStatus: 'indexed', + ragDocId: 'rag_123', + filePath: '/tmp/should-not-change', + } as never) + + expect(updated?.title).toBe('Updated title') + expect(updated?.businessStatus).toBe('valide') + expect(updated?.filePath).toBe(created.filePath) + expect(mod.getDstnyDocumentRecord(created.id)?.ragDocId).toBe('rag_123') + expect(mod.listDstnyDocuments()).toHaveLength(1) + }) + + it('filters documents by collection, product, status, and search query', async () => { + const mod = await loadModule() + const one = writeSampleFile('one.pdf') + const two = writeSampleFile('two.pdf') + mod.createDstnyDocumentRecord({ + title: 'Alianza pricing', + filePath: one, + collection: 'pricing', + product: 'alianza', + channel: 'operateur', + docType: 'pricing', + businessStatus: 'actif', + confidence: 'fort', + supplier: 'Alianza', + }) + mod.createDstnyDocumentRecord({ + title: 'Mobile benchmark', + filePath: two, + collection: 'concurrence', + product: 'mobile', + channel: 'tous', + docType: 'benchmark', + businessStatus: 'actif', + confidence: 'moyen', + }) + + expect(mod.listDstnyDocuments({ collection: 'pricing' })).toHaveLength(1) + expect(mod.listDstnyDocuments({ product: 'mobile' })).toHaveLength(1) + expect(mod.listDstnyDocuments({ q: 'alianza' }).map((doc) => doc.title)).toEqual(['Alianza pricing']) + }) + + it('excludes archived documents by default', async () => { + const mod = await loadModule() + const filePath = writeSampleFile() + const doc = mod.createDstnyDocumentRecord({ + title: 'Archived doc', + filePath, + collection: 'livrables_valides', + channel: 'interne', + docType: 'livrable', + businessStatus: 'actif', + confidence: 'fort', + }) + mod.updateDstnyDocumentRecord(doc.id, { businessStatus: 'archive', ingestionStatus: 'archived' }) + + expect(mod.listDstnyDocuments()).toHaveLength(0) + expect(mod.listDstnyDocuments({ includeArchived: true })).toHaveLength(1) + }) + + it('rejects invalid metadata values', async () => { + const mod = await loadModule() + const filePath = writeSampleFile() + expect(() => mod.createDstnyDocumentRecord({ + title: 'Bad channel', + filePath, + collection: 'pricing', + channel: 'invalid', + docType: 'pricing', + businessStatus: 'actif', + confidence: 'fort', + } as never)).toThrow(/channel must be one of/) + }) +}) diff --git a/src/server/dstny-documents.ts b/src/server/dstny-documents.ts new file mode 100644 index 0000000000..4dd62d1cf9 --- /dev/null +++ b/src/server/dstny-documents.ts @@ -0,0 +1,345 @@ +import { createHash, randomUUID } from 'node:crypto' +import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from 'node:fs' +import { homedir } from 'node:os' +import { basename, dirname, join, resolve } from 'node:path' + +export const DSTNY_DOCUMENT_COLLECTIONS = [ + 'dstny_catalogues', + 'dstny_produits', + 'metacentrex_alianza', + 'concurrence', + 'sales_enablement', + 'pricing', + 'mydstny_si', + 'github_pdfengine', + 'livrables_valides', + 'decisions', +] as const + +export const DSTNY_DOCUMENT_CHANNELS = [ + 'direct', + 'ambassadeur', + 'operateur', + 'interne', + 'tous', +] as const + +export const DSTNY_DOCUMENT_TYPES = [ + 'catalogue', + 'fiche_produit', + 'contrat', + 'slide', + 'email', + 'export_bi', + 'pricing', + 'procedure', + 'benchmark', + 'livrable', + 'decision', + 'autre', +] as const + +export const DSTNY_BUSINESS_STATUSES = [ + 'brouillon', + 'actif', + 'valide', + 'obsolete', + 'archive', +] as const + +export const DSTNY_CONFIDENCE_LEVELS = ['faible', 'moyen', 'fort'] as const + +export const DSTNY_INGESTION_STATUSES = [ + 'uploaded', + 'ready', + 'ingesting', + 'indexed', + 'error', + 'archived', +] as const + +export type DstnyDocumentCollection = typeof DSTNY_DOCUMENT_COLLECTIONS[number] +export type DstnyDocumentChannel = typeof DSTNY_DOCUMENT_CHANNELS[number] +export type DstnyDocumentType = typeof DSTNY_DOCUMENT_TYPES[number] +export type DstnyBusinessStatus = typeof DSTNY_BUSINESS_STATUSES[number] +export type DstnyConfidenceLevel = typeof DSTNY_CONFIDENCE_LEVELS[number] +export type DstnyIngestionStatus = typeof DSTNY_INGESTION_STATUSES[number] + +export type DstnyDocumentRecord = { + id: string + title: string + originalName: string + storedName: string + filePath: string + mimeType: string + sizeBytes: number + checksumSha256: string + collection: DstnyDocumentCollection + product: string | null + channel: DstnyDocumentChannel + docType: DstnyDocumentType + businessStatus: DstnyBusinessStatus + confidence: DstnyConfidenceLevel + documentDate: string | null + supplier: string | null + owner: string | null + version: string | null + summary: string | null + keywords: Array + ingestionStatus: DstnyIngestionStatus + ragDocId: string | null + ragCollection: string | null + lastError: string | null + uploadedAt: string + updatedAt: string + ingestedAt: string | null +} + +export type CreateDstnyDocumentInput = { + title: string + filePath: string + originalName?: string | null + storedName?: string | null + mimeType?: string | null + collection: DstnyDocumentCollection + product?: string | null + channel: DstnyDocumentChannel + docType: DstnyDocumentType + businessStatus: DstnyBusinessStatus + confidence: DstnyConfidenceLevel + documentDate?: string | null + supplier?: string | null + owner?: string | null + version?: string | null + summary?: string | null + keywords?: Array | string | null +} + +export type UpdateDstnyDocumentInput = Partial> + +export type DstnyDocumentFilters = { + collection?: string | null + product?: string | null + channel?: string | null + businessStatus?: string | null + ingestionStatus?: string | null + q?: string | null + includeArchived?: boolean +} + +function hermesRoot(): string { + return resolve( + process.env.HERMES_HOME?.trim() || + process.env.CLAUDE_HOME?.trim() || + join(homedir(), '.hermes'), + ) +} + +export function getDstnyDocumentRoot(): string { + return resolve( + process.env.DSTNY_DOCUMENT_ROOT?.trim() || + join(hermesRoot(), 'documents', 'dstny-inbox'), + ) +} + +export function getDstnyDocumentRegistryPath(): string { + return resolve( + process.env.DSTNY_DOCUMENT_REGISTRY?.trim() || + join(hermesRoot(), 'documents', 'dstny-documents.jsonl'), + ) +} + +function ensureRegistryDir(): void { + mkdirSync(dirname(getDstnyDocumentRegistryPath()), { recursive: true }) + mkdirSync(getDstnyDocumentRoot(), { recursive: true }) +} + +function cleanString(value: string | null | undefined): string | null { + if (typeof value !== 'string') return null + const trimmed = value.trim() + return trimmed ? trimmed : null +} + +function requireString(value: string | null | undefined, field: string): string { + const cleaned = cleanString(value) + if (!cleaned) throw new Error(`${field} is required`) + return cleaned +} + +function assertOneOf(value: unknown, allowed: T, field: string): T[number] { + if (typeof value === 'string' && (allowed as readonly string[]).includes(value)) { + return value as T[number] + } + throw new Error(`${field} must be one of: ${allowed.join(', ')}`) +} + +export function sanitizeDstnyFileName(value: string): string { + const cleaned = value + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-zA-Z0-9._-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, '') + return cleaned || 'document' +} + +export function normalizeDstnyKeywords(value: Array | string | null | undefined): Array { + const raw = Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : [] + return Array.from(new Set(raw.map((item) => item.trim()).filter(Boolean))).slice(0, 50) +} + +export function checksumFileSha256(filePath: string): string { + const hash = createHash('sha256') + hash.update(readFileSync(filePath)) + return hash.digest('hex') +} + +function readRegistryLines(): Array { + const registry = getDstnyDocumentRegistryPath() + if (!existsSync(registry)) return [] + const rows: Array = [] + for (const line of readFileSync(registry, 'utf-8').split('\n')) { + if (!line.trim()) continue + try { + rows.push(JSON.parse(line) as DstnyDocumentRecord) + } catch { + // Ignore corrupt lines; the append-only registry should remain readable. + } + } + return rows +} + +function latestById(): Map { + const docs = new Map() + for (const row of readRegistryLines()) { + if (row?.id) docs.set(row.id, row) + } + return docs +} + +export function appendDstnyDocumentRecord(record: DstnyDocumentRecord): DstnyDocumentRecord { + ensureRegistryDir() + appendFileSync(getDstnyDocumentRegistryPath(), `${JSON.stringify(record)}\n`, 'utf-8') + return record +} + +export function createDstnyDocumentRecord(input: CreateDstnyDocumentInput): DstnyDocumentRecord { + const filePath = resolve(requireString(input.filePath, 'filePath')) + if (!existsSync(filePath)) throw new Error(`File not found: ${filePath}`) + const stats = statSync(filePath) + if (!stats.isFile()) throw new Error(`Not a file: ${filePath}`) + + const now = new Date().toISOString() + const originalName = cleanString(input.originalName) || basename(filePath) + const storedName = cleanString(input.storedName) || sanitizeDstnyFileName(originalName) + + return appendDstnyDocumentRecord({ + id: `doc_${randomUUID().replace(/-/g, '').slice(0, 16)}`, + title: requireString(input.title, 'title'), + originalName, + storedName, + filePath, + mimeType: cleanString(input.mimeType) || 'application/octet-stream', + sizeBytes: stats.size, + checksumSha256: checksumFileSha256(filePath), + collection: assertOneOf(input.collection, DSTNY_DOCUMENT_COLLECTIONS, 'collection'), + product: cleanString(input.product), + channel: assertOneOf(input.channel, DSTNY_DOCUMENT_CHANNELS, 'channel'), + docType: assertOneOf(input.docType, DSTNY_DOCUMENT_TYPES, 'docType'), + businessStatus: assertOneOf(input.businessStatus, DSTNY_BUSINESS_STATUSES, 'businessStatus'), + confidence: assertOneOf(input.confidence, DSTNY_CONFIDENCE_LEVELS, 'confidence'), + documentDate: cleanString(input.documentDate), + supplier: cleanString(input.supplier), + owner: cleanString(input.owner), + version: cleanString(input.version), + summary: cleanString(input.summary), + keywords: normalizeDstnyKeywords(input.keywords), + ingestionStatus: 'uploaded', + ragDocId: null, + ragCollection: null, + lastError: null, + uploadedAt: now, + updatedAt: now, + ingestedAt: null, + }) +} + +function normalizeUpdatedRecord(current: DstnyDocumentRecord, updates: UpdateDstnyDocumentInput): DstnyDocumentRecord { + const next: DstnyDocumentRecord = { + ...current, + ...updates, + id: current.id, + originalName: current.originalName, + storedName: current.storedName, + filePath: current.filePath, + mimeType: current.mimeType, + sizeBytes: current.sizeBytes, + checksumSha256: current.checksumSha256, + uploadedAt: current.uploadedAt, + updatedAt: new Date().toISOString(), + } + + next.title = requireString(next.title, 'title') + next.collection = assertOneOf(next.collection, DSTNY_DOCUMENT_COLLECTIONS, 'collection') + next.channel = assertOneOf(next.channel, DSTNY_DOCUMENT_CHANNELS, 'channel') + next.docType = assertOneOf(next.docType, DSTNY_DOCUMENT_TYPES, 'docType') + next.businessStatus = assertOneOf(next.businessStatus, DSTNY_BUSINESS_STATUSES, 'businessStatus') + next.confidence = assertOneOf(next.confidence, DSTNY_CONFIDENCE_LEVELS, 'confidence') + next.ingestionStatus = assertOneOf(next.ingestionStatus, DSTNY_INGESTION_STATUSES, 'ingestionStatus') + next.product = cleanString(next.product) + next.documentDate = cleanString(next.documentDate) + next.supplier = cleanString(next.supplier) + next.owner = cleanString(next.owner) + next.version = cleanString(next.version) + next.summary = cleanString(next.summary) + next.ragDocId = cleanString(next.ragDocId) + next.ragCollection = cleanString(next.ragCollection) + next.lastError = cleanString(next.lastError) + next.ingestedAt = cleanString(next.ingestedAt) + next.keywords = normalizeDstnyKeywords(next.keywords) + return next +} + +export function updateDstnyDocumentRecord(id: string, updates: UpdateDstnyDocumentInput): DstnyDocumentRecord | null { + const current = latestById().get(id) + if (!current) return null + return appendDstnyDocumentRecord(normalizeUpdatedRecord(current, updates)) +} + +export function getDstnyDocumentRecord(id: string): DstnyDocumentRecord | null { + return latestById().get(id) ?? null +} + +export function listDstnyDocuments(filters: DstnyDocumentFilters = {}): Array { + let docs = Array.from(latestById().values()) + if (!filters.includeArchived) { + docs = docs.filter((doc) => doc.businessStatus !== 'archive' && doc.ingestionStatus !== 'archived') + } + if (filters.collection) docs = docs.filter((doc) => doc.collection === filters.collection) + if (filters.product) docs = docs.filter((doc) => doc.product === filters.product) + if (filters.channel) docs = docs.filter((doc) => doc.channel === filters.channel) + if (filters.businessStatus) docs = docs.filter((doc) => doc.businessStatus === filters.businessStatus) + if (filters.ingestionStatus) docs = docs.filter((doc) => doc.ingestionStatus === filters.ingestionStatus) + const q = filters.q?.trim().toLowerCase() + if (q) { + docs = docs.filter((doc) => [ + doc.title, + doc.originalName, + doc.product, + doc.supplier, + doc.summary, + ...doc.keywords, + ].filter(Boolean).some((value) => String(value).toLowerCase().includes(q))) + } + return docs.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.title.localeCompare(b.title)) +}