Add monitoring TG service

This commit is contained in:
Grendgi
2026-06-04 14:55:41 +03:00
commit f9e072774c
74 changed files with 7232 additions and 0 deletions

View File

@@ -0,0 +1,130 @@
"""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
3. built-in DEFAULT_RE_SYSTEM_PROMPT / DEFAULT_HR_SYSTEM_PROMPT
The prompt is read on every classification call but cached for a short
window so the DB isn't hit per-message. Edits via the API invalidate the
cache for that level, so a save in the UI takes effect within seconds.
"""
from __future__ import annotations
import time
from typing import Literal
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from parser_bot.db.models import AppSetting
from parser_bot.db.session import session_scope
Vertical = Literal["real_estate", "hr"]
_KEY_PREFIX = "llm_system_prompt:"
_CACHE_TTL_S = 5.0
_cache: dict[str, tuple[float, str | None]] = {}
def _key(vertical: Vertical, section_slug: str | None = None) -> str:
if section_slug:
return f"{_KEY_PREFIX}{vertical}:{section_slug}"
return f"{_KEY_PREFIX}{vertical}"
async def _load(key: str) -> str | None:
"""Read a stored prompt by exact key. None if missing or empty."""
now = time.monotonic()
cached_at, cached_value = _cache.get(key, (0.0, None))
if now - cached_at < _CACHE_TTL_S:
return cached_value
async with session_scope() as session:
row = await session.execute(
select(AppSetting.value).where(AppSetting.key == key)
)
value = row.scalar_one_or_none()
text = value if isinstance(value, str) and value.strip() else None
_cache[key] = (now, text)
return text
async def resolve(
vertical: Vertical, section_slug: str | None, default: str
) -> str:
"""Pick the most specific prompt available, falling back to `default`.
Always consults section-level → vertical-level → default. This is what
the classifier uses for every message.
"""
if section_slug:
text = await _load(_key(vertical, section_slug))
if text is not None:
return text
text = await _load(_key(vertical))
if text is not None:
return text
return default
async def get(
vertical: Vertical, 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))
if text is not None:
return text, "section"
text = await _load(_key(vertical))
if text is not None:
return text, "vertical"
return default, "default"
async def set_prompt(
vertical: Vertical, 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)
async with session_scope() as session:
stmt = (
pg_insert(AppSetting)
.values(key=key, value=text)
.on_conflict_do_update(
index_elements=["key"], set_={"value": text}
)
)
await session.execute(stmt)
invalidate(key)
async def reset(vertical: Vertical, section_slug: str | None) -> None:
"""Drop the override at the given level."""
key = _key(vertical, section_slug)
async with session_scope() as session:
await session.execute(
AppSetting.__table__.delete().where(AppSetting.key == key)
)
invalidate(key)
def invalidate(key: str | None = None) -> None:
if key is None:
_cache.clear()
else:
_cache.pop(key, None)
async def is_overridden(
vertical: Vertical, section_slug: str | None = None
) -> bool:
"""True iff a custom prompt is stored at this exact level."""
text = await _load(_key(vertical, section_slug))
return text is not None