Scope monitoring TG by department

This commit is contained in:
Grendgi
2026-06-04 15:31:10 +03:00
parent f9e072774c
commit b78d1eac02
27 changed files with 481 additions and 553 deletions

View File

@@ -1,8 +1,8 @@
"""Runtime-editable LLM system prompts, persisted in app_settings.
Three resolution levels with fallback (more specific → less specific):
1. `llm_system_prompt:<vertical>:<section_slug>` — section override
2. `llm_system_prompt:<vertical>` — vertical override
1. `llm_system_prompt:<department_id>:<vertical>:<section_slug>` — section override
2. `llm_system_prompt:<department_id>:<vertical>` — department vertical override
3. built-in DEFAULT_RE_SYSTEM_PROMPT / DEFAULT_HR_SYSTEM_PROMPT
The prompt is read on every classification call but cached for a short
@@ -27,10 +27,15 @@ _CACHE_TTL_S = 5.0
_cache: dict[str, tuple[float, str | None]] = {}
def _key(vertical: Vertical, section_slug: str | None = None) -> str:
def _key(
vertical: Vertical,
department_id: str | None,
section_slug: str | None = None,
) -> str:
dept = department_id or "global"
if section_slug:
return f"{_KEY_PREFIX}{vertical}:{section_slug}"
return f"{_KEY_PREFIX}{vertical}"
return f"{_KEY_PREFIX}{dept}:{vertical}:{section_slug}"
return f"{_KEY_PREFIX}{dept}:{vertical}"
async def _load(key: str) -> str | None:
@@ -52,7 +57,7 @@ async def _load(key: str) -> str | None:
async def resolve(
vertical: Vertical, section_slug: str | None, default: str
vertical: Vertical, department_id: str | None, section_slug: str | None, default: str
) -> str:
"""Pick the most specific prompt available, falling back to `default`.
@@ -60,39 +65,39 @@ async def resolve(
the classifier uses for every message.
"""
if section_slug:
text = await _load(_key(vertical, section_slug))
text = await _load(_key(vertical, department_id, section_slug))
if text is not None:
return text
text = await _load(_key(vertical))
text = await _load(_key(vertical, department_id))
if text is not None:
return text
return default
async def get(
vertical: Vertical, section_slug: str | None, default: str
vertical: Vertical, department_id: str | None, section_slug: str | None, default: str
) -> tuple[str, str]:
"""For the settings UI: return (text, source) where source is one of
'section' | 'vertical' | 'default'. Lets the editor show which override
is currently active without a second round-trip.
"""
if section_slug:
text = await _load(_key(vertical, section_slug))
text = await _load(_key(vertical, department_id, section_slug))
if text is not None:
return text, "section"
text = await _load(_key(vertical))
text = await _load(_key(vertical, department_id))
if text is not None:
return text, "vertical"
return default, "default"
async def set_prompt(
vertical: Vertical, section_slug: str | None, text: str
vertical: Vertical, department_id: str | None, section_slug: str | None, text: str
) -> None:
"""Save a new prompt at the given level (section or vertical)."""
if not isinstance(text, str) or not text.strip():
raise ValueError("prompt must be a non-empty string")
key = _key(vertical, section_slug)
key = _key(vertical, department_id, section_slug)
async with session_scope() as session:
stmt = (
pg_insert(AppSetting)
@@ -105,9 +110,11 @@ async def set_prompt(
invalidate(key)
async def reset(vertical: Vertical, section_slug: str | None) -> None:
async def reset(
vertical: Vertical, department_id: str | None, section_slug: str | None
) -> None:
"""Drop the override at the given level."""
key = _key(vertical, section_slug)
key = _key(vertical, department_id, section_slug)
async with session_scope() as session:
await session.execute(
AppSetting.__table__.delete().where(AppSetting.key == key)
@@ -123,8 +130,8 @@ def invalidate(key: str | None = None) -> None:
async def is_overridden(
vertical: Vertical, section_slug: str | None = None
vertical: Vertical, department_id: str | None, section_slug: str | None = None
) -> bool:
"""True iff a custom prompt is stored at this exact level."""
text = await _load(_key(vertical, section_slug))
text = await _load(_key(vertical, department_id, section_slug))
return text is not None