Nomio4640's picture
submodule
59f9987
"""
Admin router — CRUD for knowledge base entries, custom labels, and stopwords.
Key change from original:
Uses services.kb (the shared KnowledgeBase singleton) instead of creating
its own private _kb instance. This is the critical fix: when the admin
adds a stopword here, analysis.py's preprocessor can be reloaded from the
same DB connection via POST /reload.
Original had:
_kb = None
def get_kb():
global _kb
if _kb is None:
_kb = KnowledgeBase(db_path=DB_PATH)
return _kb
This is a different object from analysis.py's preprocessor — they don't
share state. Changes made here were invisible to the analysis pipeline.
"""
from typing import List
from fastapi import APIRouter, HTTPException
from adapters.api.schemas import (
KnowledgeEntryRequest, KnowledgeEntryResponse,
LabelRequest, StopwordRequest,
)
from adapters.api import services # shared KB singleton
from nlp_core.models import KnowledgeEntry
router = APIRouter()
# Convenience alias — same instance as analysis.py uses
kb = services.kb
# ---------------------------------------------------------------------------
# Knowledge entries
# ---------------------------------------------------------------------------
@router.get("/knowledge", response_model=List[KnowledgeEntryResponse])
async def list_entries(category: str = None):
"""List all knowledge base entries, optionally filtered by category."""
entries = kb.get_entries(category=category)
return [_entry_to_response(e) for e in entries]
@router.post("/knowledge", response_model=KnowledgeEntryResponse)
async def create_entry(request: KnowledgeEntryRequest):
"""Add a new knowledge base entry."""
entry = KnowledgeEntry(
word=request.word,
category=request.category,
entity_type=request.entity_type,
synonyms=request.synonyms or [],
)
entry_id = kb.add_entry(entry)
return KnowledgeEntryResponse(
id=entry_id, word=entry.word, category=entry.category,
entity_type=entry.entity_type, synonyms=entry.synonyms,
)
@router.put("/knowledge/{entry_id}")
async def update_entry(entry_id: int, request: KnowledgeEntryRequest):
"""Update an existing knowledge base entry."""
entry = KnowledgeEntry(
word=request.word,
category=request.category,
entity_type=request.entity_type,
synonyms=request.synonyms or [],
)
ok = kb.update_entry(entry_id, entry)
if not ok:
raise HTTPException(status_code=404, detail=f"Entry {entry_id} not found")
return {"status": "updated", "id": entry_id}
@router.delete("/knowledge/{entry_id}")
async def delete_entry(entry_id: int):
"""Delete a knowledge base entry."""
kb.delete_entry(entry_id)
return {"status": "deleted", "id": entry_id}
@router.get("/knowledge/categories", response_model=List[str])
async def list_categories():
"""List all distinct category values in the knowledge base."""
return kb.get_categories()
# ---------------------------------------------------------------------------
# Custom labels
# ---------------------------------------------------------------------------
@router.get("/labels")
async def list_labels(label_type: str = "entity"):
"""
Return all custom label mappings as a dict: {original_label: custom_label}.
Example response:
{"PER": "Улс төрч", "LOC": "Байршил"}
These are applied to NER output in analysis.py so the frontend
shows human-readable Mongolian labels instead of PER/LOC/ORG.
"""
return kb.get_labels(label_type=label_type)
@router.post("/labels")
async def create_label(request: LabelRequest):
"""
Create or update a custom label mapping.
After saving, call POST /reload so analysis.py picks up the new mapping.
"""
kb.set_label(
request.original_label,
request.custom_label,
request.label_type,
)
return {
"status": "created",
"original": request.original_label,
"custom": request.custom_label,
}
@router.delete("/labels/{label_id}")
async def delete_label(label_id: int):
"""Delete a custom label by its DB id."""
kb.delete_label(label_id)
return {"status": "deleted", "id": label_id}
# ---------------------------------------------------------------------------
# Stopwords
# ---------------------------------------------------------------------------
@router.get("/stopwords", response_model=List[str])
async def list_stopwords():
"""
List all custom stopwords saved by the admin.
These are in ADDITION to the hardcoded MONGOLIAN_STOPWORDS in
preprocessing.py. They take effect after POST /reload is called.
"""
return kb.get_stopwords()
@router.post("/stopwords")
async def add_stopword(request: StopwordRequest):
"""
Add a custom stopword.
After saving, call POST /reload so the preprocessor picks it up.
Topic modeling will then exclude this word from topic vocabulary.
"""
kb.add_stopword(request.word.lower().strip())
return {"status": "added", "word": request.word}
@router.delete("/stopwords/{word}")
async def delete_stopword(word: str):
"""Remove a custom stopword."""
kb.delete_stopword(word)
return {"status": "deleted", "word": word}
# ---------------------------------------------------------------------------
# Reload trigger
# ---------------------------------------------------------------------------
@router.post("/reload")
async def reload():
"""
Apply admin changes to the live preprocessor without restarting.
Call this from the Admin frontend after:
- Adding or removing custom stopwords
- Adding or updating custom entity labels
Returns the count of currently active custom stopwords so the UI
can confirm the reload worked.
"""
services.reload_preprocessor()
return {
"status": "reloaded",
"custom_stopword_count": len(kb.get_stopwords()),
"custom_label_count": len(kb.get_labels()),
}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _entry_to_response(e: KnowledgeEntry) -> KnowledgeEntryResponse:
return KnowledgeEntryResponse(
id=e.id,
word=e.word,
category=e.category,
entity_type=e.entity_type,
synonyms=e.synonyms,
)