New router intelligence modules (26 files): alert_ingest/store, audit_store, architecture_pressure, backlog_generator/store, cost_analyzer, data_governance, dependency_scanner, drift_analyzer, incident_* (5 files), llm_enrichment, platform_priority_digest, provider_budget, release_check_runner, risk_* (6 files), signature_state_store, sofiia_auto_router, tool_governance New services: - sofiia-console: Dockerfile, adapters/, monitor/nodes/ops/voice modules, launchd, react static - memory-service: integration_endpoints, integrations, voice_endpoints, static UI - aurora-service: full app suite (analysis, job_store, orchestrator, reporting, schemas, subagents) - sofiia-supervisor: new supervisor service - aistalk-bridge-lite: Telegram bridge lite - calendar-service: CalDAV calendar service with reminders - mlx-stt-service / mlx-tts-service: Apple Silicon speech services - binance-bot-monitor: market monitor service - node-worker: STT/TTS memory providers New tools (9): agent_email, browser_tool, contract_tool, observability_tool, oncall_tool, pr_reviewer_tool, repo_tool, safe_code_executor, secure_vault New crews: agromatrix_crew (10 modules: depth_classifier, doc_facts, doc_focus, farm_state, light_reply, llm_factory, memory_manager, proactivity, reflection_engine, session_context, style_adapter, telemetry) Tests: 85+ test files for all new modules Made-with: Cursor
187 lines
7.3 KiB
Python
187 lines
7.3 KiB
Python
"""
|
||
Style Adapter для Степана.
|
||
|
||
adapt_response_style(response, user_profile) → str
|
||
|
||
Не змінює зміст відповіді, лише форму:
|
||
concise → скорочує, прибирає пояснення
|
||
checklist → переформатовує у маркери
|
||
analytical → додає блок "Причина / Наслідок"
|
||
detailed → дозволяє довшу форму (без змін)
|
||
conversational → за замовчуванням, без змін
|
||
|
||
Стиль визначається:
|
||
1. Явні слова користувача ("коротко", "списком", ...)
|
||
2. Поле user_profile["style"]
|
||
|
||
Fail-safe: будь-який виняток → повертає оригінальну відповідь.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import re
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
# ─── Sentence splitter ───────────────────────────────────────────────────────
|
||
|
||
_SENT_SPLIT_RE = re.compile(r'(?<=[.!?])\s+')
|
||
|
||
|
||
def _split_sentences(text: str) -> list[str]:
|
||
return [s.strip() for s in _SENT_SPLIT_RE.split(text.strip()) if s.strip()]
|
||
|
||
|
||
# ─── Style transformers ──────────────────────────────────────────────────────
|
||
|
||
def _to_concise(text: str) -> str:
|
||
"""Скоротити до 2–3 речень, прибрати надлишкові вступні фрази."""
|
||
# Remove common filler openings
|
||
filler_re = re.compile(
|
||
r'^(звісно[,!]?\s*|звичайно[,!]?\s*|добре[,!]?\s*|зрозуміло[,!]?\s*'
|
||
r'|окей[,!]?\s*|ок[,!]?\s*|чудово[,!]?\s*|ось[,!]?\s*|так[,!]?\s*)',
|
||
re.IGNORECASE | re.UNICODE,
|
||
)
|
||
text = filler_re.sub('', text).strip()
|
||
|
||
sentences = _split_sentences(text)
|
||
if len(sentences) <= 3:
|
||
return text
|
||
# Keep first 3 meaningful sentences
|
||
short = ' '.join(sentences[:3])
|
||
if len(sentences) > 3:
|
||
short += ' …'
|
||
return short
|
||
|
||
|
||
def _to_checklist(text: str) -> str:
|
||
"""
|
||
Переформатовує відповідь у маркований список.
|
||
Якщо вже є маркери — повертає без змін.
|
||
"""
|
||
if re.search(r'^\s*[-•*]\s', text, re.MULTILINE):
|
||
return text # already formatted
|
||
|
||
sentences = _split_sentences(text)
|
||
if len(sentences) < 2:
|
||
return text # too short to convert
|
||
|
||
items = '\n'.join(f'• {s}' for s in sentences)
|
||
return items
|
||
|
||
|
||
def _to_analytical(text: str) -> str:
|
||
"""
|
||
Додає короткий блок «Чому це важливо:» якщо відповідь досить довга.
|
||
Не дублює зміст — тільки додає структуру.
|
||
"""
|
||
sentences = _split_sentences(text)
|
||
if len(sentences) < 3:
|
||
return text
|
||
|
||
# First 2 sentences — основа; решта — обґрунтування
|
||
main = ' '.join(sentences[:2])
|
||
reason = ' '.join(sentences[2:4])
|
||
result = main
|
||
if reason:
|
||
result += f'\n\n*Чому це важливо:* {reason}'
|
||
return result
|
||
|
||
|
||
# ─── Style detection from text ───────────────────────────────────────────────
|
||
|
||
_STYLE_SIGNAL: dict[str, list[str]] = {
|
||
"concise": ["коротко", "без деталей", "стисло", "коротку відповідь", "кратко"],
|
||
"checklist": ["списком", "маркерами", "у списку", "по пунктах", "пунктами"],
|
||
"analytical": ["аналіз", "причини", "наслідки", "детальний аналіз", "розбери"],
|
||
"detailed": ["детально", "докладно", "розгорнуто", "повністю", "докладну"],
|
||
}
|
||
|
||
|
||
def detect_style_from_text(text: str) -> str | None:
|
||
"""Визначити бажаний стиль з тексту повідомлення."""
|
||
tl = text.lower()
|
||
for style, signals in _STYLE_SIGNAL.items():
|
||
if any(s in tl for s in signals):
|
||
return style
|
||
return None
|
||
|
||
|
||
# ─── Main adapter ────────────────────────────────────────────────────────────
|
||
|
||
def adapt_response_style(response: str, user_profile: dict | None) -> str:
|
||
"""
|
||
Адаптувати відповідь під стиль користувача.
|
||
|
||
Якщо user_profile відсутній або style не визначено — повертає оригінал.
|
||
Fail-safe: будь-який виняток → повертає оригінал.
|
||
"""
|
||
try:
|
||
if not response or not user_profile:
|
||
return response
|
||
|
||
style = user_profile.get("style") or "conversational"
|
||
|
||
if style == "concise":
|
||
adapted = _to_concise(response)
|
||
elif style == "checklist":
|
||
adapted = _to_checklist(response)
|
||
elif style == "analytical":
|
||
adapted = _to_analytical(response)
|
||
else:
|
||
# "detailed" and "conversational" — no transformation
|
||
adapted = response
|
||
|
||
if adapted != response:
|
||
logger.debug("style_adapter: style=%s original_len=%d adapted_len=%d", style, len(response), len(adapted))
|
||
|
||
return adapted
|
||
|
||
except Exception as exc:
|
||
logger.warning("style_adapter: failed (returning original): %s", exc)
|
||
return response
|
||
|
||
|
||
def build_style_prefix(user_profile: dict | None) -> str:
|
||
"""
|
||
Сформувати prefix для system prompt Степана з урахуванням профілю.
|
||
Використовується у _stepan_light_response і фінальній задачі Deep mode.
|
||
"""
|
||
if not user_profile:
|
||
return ""
|
||
|
||
parts: list[str] = []
|
||
|
||
name = user_profile.get("name")
|
||
if name:
|
||
parts.append(f"Користувача звати {name}.")
|
||
|
||
role = user_profile.get("role", "unknown")
|
||
role_labels = {
|
||
"owner": "власник/керівник господарства",
|
||
"agronomist": "агроном",
|
||
"operator": "оператор",
|
||
"mechanic": "механік",
|
||
}
|
||
if role in role_labels:
|
||
parts.append(f"Його роль: {role_labels[role]}.")
|
||
|
||
style = user_profile.get("style", "conversational")
|
||
style_instructions = {
|
||
"concise": "Відповідай стисло, 1–2 речення, без зайвих вступів.",
|
||
"checklist": "Якщо доречно — структуруй відповідь у маркований список.",
|
||
"analytical": "Якщо доречно — виділи причину і наслідок.",
|
||
"detailed": "Можеш відповідати розгорнуто.",
|
||
"conversational": "Говори природно, живою мовою.",
|
||
}
|
||
if style in style_instructions:
|
||
parts.append(style_instructions[style])
|
||
|
||
summary = user_profile.get("interaction_summary")
|
||
if summary:
|
||
parts.append(f"Контекст про користувача: {summary}")
|
||
|
||
return " ".join(parts)
|