Date : 2026-03-23
Scope : tout le code source (src/felix/, tests/, evals/, web/)
Reviewer : Claude Opus 4.6
Felix est un projet bien structuré avec un pipeline d'ingest sophistiqué, une couche graph Neo4j propre, et un frontend Vue/Nuxt fonctionnel. La qualité globale est bonne — config Ruff stricte, mypy strict, bonne séparation frontend/backend, usage correct de pydantic-ai.
Les axes d'amélioration les plus impactants :
repository.pyest un God Module (1021 lignes, ~15 domaines mélangés)- Usage systématique de
dict[str, Any]là où des modèles typés élimineraient des bugs - Des fonctions privées (
_build_model,_emit,_normalize) exportées et utilisées partout - Des écritures Neo4j séquentielles évitables avec
UNWIND run_import_pipelinea trop de responsabilités (3 noqa: PLR supprimés)
Fichier : supprimé
Severite : src/felix/graph/repository.py (1021 lignes)Haute Résolu
Ce module unique gère les requêtes Cypher pour tous les domaines.
Résolu : découpé en 7 modules domaine dans graph/repositories/ :
characters.py, groups.py, locations.py, scenes.py, timeline.py,
issues.py, beats.py. Re-export via __init__.py.
Fichier : → src/felix/agent/chat_agent.py:47src/felix/llm.py
Severite : Moyenne Résolu
Résolu : _build_model renommée build_model et déplacée dans src/felix/llm.py.
5 importeurs mis à jour.
Fichiers : ingest/pipeline.py, ingest/orchestrator.py
Severite : Moyenne
pipeline.pyimporteSceneOrchestrator,make_scene_iddepuisorchestrator.pyorchestrator.pyimporte_PipelineContextdepuispipeline.py(viaTYPE_CHECKING)
La guard TYPE_CHECKING évite le crash, mais la structure reste fragile.
_PipelineContext devrait vivre dans un module tiers (ingest/context.py)
ou bien orchestrator.py ne devrait pas en dépendre.
Fichier : ingest/pipeline.py
Severite : Haute Résolu
Résolu : extrait en 4 sous-fonctions (_segment_scene_files,
_create_agents, _process_scene_unit, _process_all_scenes,
_setup_pipeline). PLR0912 et PLR0915 supprimés de run_import_pipeline.
Fichier : ingest/resolution.py
Severite : Faible Résolu
Résolu : préfixe _ retiré de emit, handle_ambiguous_character,
resolve_characters, resolve_location.
Fichier : src/felix/graph/repositories/_types.py
Severite : Haute Résolu
Toutes les fonctions repository retournent dict[str, Any].
Résolu : 20 TypedDict dans _types.py, 19 fonctions de lecture typées
avec cast() string (no-op runtime). Pattern deux classes pour les nodes
avec champs optionnels (_Required + total=False). Imports sous
TYPE_CHECKING — zéro coût runtime.
Fichier : ingest/orchestrator.py:74-81
Severite : Moyenne
@dataclass
class SceneOrchestrator:
analyzer: Any
timeline_checker: Any
narrative_checker: Any
profiler: Any = None
# ...Ces champs devraient être AnalyzerAgents, Agent[None, ConsistencyReport],
Agent[None, CharacterProfile], etc. L'usage de Any annule le bénéfice
de mypy --strict.
Fichier : ingest/models.py
Severite : Faible Résolu
Résolu : type et severity typés avec Literal.
Fichier : api/deps.py:49
Severite : Faible
ChatAgent: TypeAlias = Annotated[Agent, Depends(get_agent)]Agent est non-paramétré — on perd Agent[FelixDeps, str].
Devrait être Annotated[Agent[FelixDeps, str], Depends(get_agent)].
Fichier : cli.py:52
Severite : Faible
message_history: list[object] = [] devrait utiliser les types pydantic-ai
(list[ModelMessage]) pour bénéficier du typage.
Fichier : cli.py
Severite : Moyenne Résolu
Résolu : datetime.now(UTC) dans les deux occurrences.
Fichier : ingest/entity_checker.py:142-149
Severite : Moyenne
async with driver.session() as session:
result = await session.run(
"MATCH (s:Scene) WHERE s.id IN $ids RETURN s.id AS id, s.raw_text AS raw_text",
ids=scene_ids,
)Cypher exécuté directement dans une fonction business, byppassant la couche
repository. Si le schéma change, ce query ne sera pas maintenu avec le reste.
Devrait être une fonction dans repository.py (ex: get_scenes_raw_text).
Fichier : repository.py:267-274
Severite : Faible
_notif = logging.getLogger("neo4j.notifications")
_prev = _notif.level
_notif.setLevel(logging.ERROR)
try:
async with driver.session() as session:
return await session.execute_read(_read)
finally:
_notif.setLevel(_prev)Workaround pour masquer des warnings Neo4j. Pas thread-safe, et masque
potentiellement de vrais problèmes. Mieux : filtrer le warning spécifique
avec un logging.Filter.
Fichier : ingest/entity_checker.py:194-201
Severite : Faible
model = _build_model(model_name, base_url)
agent: Agent[None, ConsistencyReport] = Agent(
model, instructions=ENTITY_CHECK_PROMPT, ...
)
result = await agent.run(...)L'agent est recréé à chaque appel de check_character_consistency.
Tous les autres agents du pipeline sont créés une fois et réutilisés.
Cet agent devrait suivre le même pattern.
Fichier : ingest/utils.py
Severite : Faible Résolu
Résolu : normalize() centralisée dans ingest/utils.py, supprimée
de resolver.py et entity_checker.py.
Fichiers : writer.py:31-88, loader.py:54-72
Severite : Haute
Chaque personnage/groupe est écrit avec un await tx.run(...) individuel :
for char in characters:
await tx.run("MERGE (c:Character {id: $id})...", ...)
await tx.run("MATCH (c:Character)...", ...)Pour une scène avec 10 personnages, c'est 20 round-trips Cypher dans la
même transaction. UNWIND permet de tout faire en 2 requêtes :
UNWIND $chars AS char
MERGE (c:Character {id: char.id})
ON CREATE SET c.name = char.name
WITH c, char
MATCH (s:Scene {id: $scene_id})
MERGE (c)-[:PRESENT_IN {role: char.role}]->(s)Fichier : api/routes/export.py:15-27
Severite : Moyenne
9 requêtes Neo4j exécutées séquentiellement :
characters = await repository.list_all_characters_full(driver),
locations = await repository.list_all_locations(driver),
scenes = await repository.list_all_scenes_full(driver),
# ... 6 de plusToutes sont indépendantes et pourraient être parallélisées avec
asyncio.gather.
Fichier : api/routes/characters.py:125-153
Severite : Faible
Deux requêtes séparées (get_character_profile + get_character_relations)
pour construire un CharacterDetail. Une seule requête Cypher avec
OPTIONAL MATCH suffirait.
Fichier : ingest/checker.py:162-189
Severite : Moyenne
Les deux agents (timeline + narrative) sont appelés séquentiellement. Ils sont indépendants et pourraient être parallélisés :
timeline_report, narrative_report = await asyncio.gather(
timeline_agent.run(...), narrative_agent.run(...)
)Fichier : api/routes/ingest.py:56-90
Severite : Moyenne
L'endpoint /api/import accepte les fichiers sans limite de taille.
Un fichier de plusieurs GB ferait OOM. Ajouter une vérification :
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
for upload in txt_files:
content = await upload.read()
if len(content) > MAX_FILE_SIZE:
raise HTTPException(413, "File too large")Fichiers : api/routes/ingest.py, api/routes/chat.py
Severite : Faible (local-first)
Pas critique vu le modèle de déploiement local, mais si l'API
est un jour exposée, les endpoints /api/import et /api/chat
pourraient être abusés (coût LLM, saturation Neo4j).
Fichier : api/main.py:50-55
Severite : Faible
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3007"],
allow_methods=["*"],
allow_headers=["*"],
)L'origin est hardcodée. Devrait être configurable via settings pour
faciliter un éventuel déploiement.
Fichier : ingest/resolution.py
Severite : Moyenne
_resolve_characters (lines 173-235) et _resolve_location (lines 238-333)
partagent ~80% de leur logique :
- Fuzzy match
- Gestion AmbiguousMatch avec clarification
- Création d'issues
- Emission d'événements SSE
- Gestion du timeout
La duplication est massive (~80 lignes). Un refactor avec un
_resolve_entity_generic() qui accepte le type d'entité réduirait
le code de moitié.
Fichier : vectorstore/store.py
Severite : Faible Résolu
Résolu : _EMBEDDING_MODEL = settings.segmenter_embedding_model.
Fichier : ingest.py:214-217
Severite : Faible
def _tmp_path(tmp_dir: str) -> Path:
from pathlib import Path
return Path(tmp_dir)Juste Path(tmp_dir) inline ferait l'affaire. Le lazy import de Path
n'apporte rien.
L'usage de pydantic-ai est globalement correct et idiomatique pour la version 1.68+ :
MistralModel+MistralProvider/OpenAIModel+OpenAIProviderAgent(model, instructions=..., output_type=..., model_settings=...)agent.run(),agent.iter()avec streamingModelMessagesTypeAdapterpour sérialisation/désérialisationAgent.is_model_request_node()pour le streaming node-by-node@agent.output_validatoravecModelRetryRunContext[FelixDeps]pour le DI des tools
Point d'attention : le retries=2 est un bon default pour des modèles 7B,
mais considérer retries=3 pour les agents critiques (analyzer, profiler)
si le budget le permet.
Fichier : tests/
Severite : Moyenne
Chaque test nécessite une instance Neo4j live. Il n'y a aucun test unitaire pur (mock du driver). Conséquences :
- CI lente (démarrage Neo4j + cleanup)
- Tests impossibles sans Docker/infra
- Pas de tests isolés du resolver, segmenter (sauf
test_segmenter.py)
Le segmenter, le resolver (fuzzy matching), les formatters sont des candidats idéaux pour des tests unitaires purs.
Severite : Moyenne
L'orchestrator et le pipeline n'ont pas de tests directs (hors evals). Les evals sont des tests aspirationnels, pas des tests de non-régression. Un test d'intégration avec un fichier fixture minimal et un mock LLM sécuriserait les refactors.
Fichier : web/app/pages/characters/[id].vue
Severite : Faible
saveProfile(), addRelation(), removeRelation() n'ont pas de
catch ni de feedback utilisateur en cas d'erreur réseau/API.
Le finally reset le loading mais l'erreur est silencieusement ignorée.
Fichier : web/app/types/index.ts
Severite : Faible
Les interfaces TypeScript sont maintenues manuellement en miroir des
modèles Pydantic. Risque de dérive. Considérer un outil de génération
automatique (ex: datamodel-code-generator ou openapi-typescript
depuis le schéma OpenAPI de FastAPI).
Fichier : web/app/composables/useFelix.ts
Severite : Faible
Le composable implémente la lecture SSE à la main. Partagé avec rien
d'autre — le composable useImport fait probablement la même chose.
Un utilitaire useSSEFetch réutilisable serait plus propre.
- Config Ruff exhaustive avec
S(bandit),ASYNC,TCH,N— la sécurité et les bonnes pratiques async sont monitorées - Lazy loading du modèle d'embedding dans
TextSegmenter— bon pattern pour éviter le chargement quand non nécessaire TYPE_CHECKINGguards systématiques pour les imports lourds (neo4j, chromadb, sentence_transformers)- Clarification flow avec asyncio.Event + timeout — design élégant pour le human-in-the-loop pendant l'import
- Seed data riche et réaliste dans
graph/seed.py— excellent pour les tests - Evals framework séparé de pytest avec des evaluators composables
- Prompts LLM bien structurés avec rules, examples, et anti-patterns explicites
_coverage_scoredans le resolver — la pénalité sqrt(n_q/n_c) pour éviter les faux positifs token_set_ratio est une bonne trouvaille
| # | Action | Severite | Effort |
|---|---|---|---|
| 1 | repository.py en modules domaine |
✅ Fait | Moyen |
| 2 | ✅ Fait | Moyen | |
| 3 | N/A | — | |
| 4 | run_import_pipeline en sous-fonctions |
✅ Fait | Faible |
| 5 | _build_model → build_model, déplacer dans llm.py |
✅ Fait | Faible |
| 6 | Extraire _PipelineContext dans ingest/context.py |
Moyenne | Faible |
| 7 | Paralléliser l'export avec asyncio.gather |
Moyenne | Faible |
| 8 | Paralléliser les deux checker agents | Moyenne | Faible |
| 9 | _normalize dans ingest/utils.py |
✅ Fait | Faible |
| 10 | Ajouter validation de taille sur les uploads | Moyenne | Faible |
| 11 | ✅ Fait | Faible | |
| 12 | Ajouter des tests unitaires (resolver, segmenter, formatters) | Moyenne | Moyen |
| 13 | datetime.now(timezone.utc) dans l'export CLI |
✅ Fait | Trivial |
| 14 | ConsistencyIssue.type/severity avec Literal |
✅ Fait | Trivial |
-
cli.py—datetime.now()→datetime.now(UTC)✅ -
ingest/models.py—ConsistencyIssue.typeet.severitytypésLiteral✅ -
vectorstore/store.py—_EMBEDDING_MODELunifié viasettings✅ -
ingest/utils.py—normalize()centralisée ✅ -
resolver.py—_normalizesupprimée, importe depuisutils✅ -
entity_checker.py—_normalizesupprimée, importe depuisutils✅ -
api/routes/ingest.py:214-217— Supprimer la fonction_tmp_path, remplacer les appels parPath(tmp_dir)directement -
agent/chat_agent.py—_build_modeldéplacée danssrc/felix/llm.py✅ - 5 importeurs — mis à jour vers
felix.llm.build_model✅ -
resolution.py— préfixe_retiré deemit,handle_ambiguous_character,resolve_characters,resolve_location✅
-
api/routes/export.py:15-27— Wrapper les 9 appels repository dans unasyncio.gather:chars, locs, scenes, events, char_events, rels, frags, beats, issues = await asyncio.gather( repository.list_all_characters_full(driver), repository.list_all_locations(driver), # ... )
-
ingest/checker.py:162-189— Paralléliser les appels timeline_agent et narrative_agent avecasyncio.gather -
graph/writer.py:55-69— Remplacer la bouclefor char in characters: await tx.run(...)par une requeteUNWIND $chars AS char MERGE (c:Character {id: char.id})... -
graph/writer.py:71-85— Idem pour les groups : remplacer la boucle parUNWIND -
ingest/loader.py:54-62— Remplacer la boucle character upsert + fragment par deux requetesUNWIND -
ingest/loader.py:65-71— Idem pour les groups dans loader -
ingest/loader.py:90-93— Idem pour les character events
-
api/routes/ingest.py— Ajouter une constanteMAX_UPLOAD_SIZE = 10 * 1024 * 1024et verifierlen(content) > MAX_UPLOAD_SIZEapres chaqueawait upload.read(), leverHTTPException(413) -
config.py— Ajouter un champcors_origins: list[str] = ["http://localhost:3007"] -
api/main.py:51— Remplacer l'origin hardcodee parsettings.cors_origins
- Creer
src/felix/graph/repositories/avec__init__.py✅ - Extraire
characters.py✅ - Extraire
scenes.py✅ - Extraire
locations.py✅ - Extraire
timeline.py✅ - Extraire
issues.py✅ - Extraire
beats.py✅ - Extraire
groups.py✅ - Mettre a jour
graph/repositories/__init__.py✅ - Mettre a jour tous les imports ✅
- Supprimer l'ancien
repository.py✅
- Creer
src/felix/ingest/context.py— Y deplacer_PipelineContext,ImportProgress,ImportStatus(depuisresolution.py) -
ingest/pipeline.py— Extraire une fonction_setup_agents(model_name, base_url, enrich_profiles) -> tuple[...]qui cree tous les agents -
ingest/pipeline.py— Extraire une fonction_segment_files(scene_files, segmenter, queue) -> list[tuple[Path, int, int, str]] -
ingest/pipeline.py— Extraire une fonction_process_scene_unit(orchestrator, scene_file, scene_id, chunk_text, ...) -> ...pour le corps de la boucle -
ingest/resolution.py— Factoriser_resolve_characterset_resolve_locationdans une fonction generique_resolve_entity(entity_type, name, registry, aliases, ...)avec le code de clarification partage -
entity_checker.py:142-149— Extraire le Cypher brut dans une fonction repositoryget_scenes_raw_text(driver, scene_ids) -> dict[str, str] -
entity_checker.py:194-201— Transformercheck_character_consistencypour accepter un agent en parametre au lieu de le recreer a chaque appel
-
graph/repositories/_types.py— 20 TypedDict créés, 19 fonctions de lecture typées ✅ -
ingest/orchestrator.py:74-81— Remplaceranalyzer: AnyparAnalyzerAgents,profiler: AnyparAgent[None, CharacterProfile] | None, etc. -
api/deps.py:49— ChangerChatAgent: TypeAlias = Annotated[Agent, ...]enAnnotated[Agent[FelixDeps, str], ...] -
cli.py:52— Remplacerlist[object]par le type pydantic-ailist[ModelMessage] -
api/models.py— AjouterLiteralpourIssue.type,Issue.severity(en plus deConsistencyIssue)
- Creer
tests/test_resolver_unit.py— Tests unitaires purs pourfuzzy_match_entity,slugify,_normalize,_coverage_score,_has_different_first_namesans Neo4j - Creer
tests/test_segmenter_unit.py— Tests supplementaires pour_split_into_blocks,_group_blocks,_merge_small_segments,_apply_overlap( logique pure, pas besoin d'embedding) - Creer
tests/test_formatters_unit.py— Tests pour_format_character_profileavec des dicts en entree (pas de Neo4j) - Creer
tests/test_pipeline_integration.py— Test d'integration avec un fichier fixture minimal, mock des agents LLM, verification que les nodes Neo4j sont crees correctement
-
web/app/pages/characters/[id].vue— Ajouter uncatchavecuseToast()sursaveProfile(),addRelation(),removeRelation()pour afficher les erreurs -
web/nuxt.config.ts— Ajouter un scriptpostbuildougenerate:typesqui generetypes/index.tsdepuis le schema OpenAPI de FastAPI (viaopenapi-typescript) -
web/app/composables/— Extraire un utilitaireuseSSEFetchreutilisable depuisuseFelix.tsetuseImport.ts