kin: KIN-BIZ-006 Проверить промпт sysadmin.md на поддержку сценария env_scan

This commit is contained in:
Gros Frumos 2026-03-16 19:26:51 +02:00
parent 531275e4ce
commit a58578bb9d
14 changed files with 1619 additions and 13 deletions

48
core/chat_intent.py Normal file
View file

@ -0,0 +1,48 @@
"""Kin — chat intent classifier (heuristic, no LLM).
classify_intent(text) 'task_request' | 'status_query' | 'question'
"""
import re
from typing import Literal
_STATUS_PATTERNS = [
r'что сейчас',
r'в работе',
r'\bстатус\b',
r'список задач',
r'покажи задачи',
r'покажи список',
r'какие задачи',
r'что идёт',
r'что делается',
r'что висит',
]
_QUESTION_STARTS = (
'почему', 'зачем', 'как ', 'что такое', 'что значит',
'объясни', 'расскажи', 'что делает', 'как работает',
'в чём', 'когда', 'кто',
)
def classify_intent(text: str) -> Literal['task_request', 'status_query', 'question']:
"""Classify user message intent.
Returns:
'status_query' user is asking about current project status/tasks
'question' user is asking a question (no action implied)
'task_request' everything else; default: create a task and run pipeline
"""
lower = text.lower().strip()
for pattern in _STATUS_PATTERNS:
if re.search(pattern, lower):
return 'status_query'
if lower.endswith('?'):
for word in _QUESTION_STARTS:
if lower.startswith(word):
return 'question'
return 'task_request'

View file

@ -224,6 +224,24 @@ CREATE TABLE IF NOT EXISTS support_bot_config (
escalation_keywords JSON
);
-- Среды развёртывания проекта (prod/dev серверы)
CREATE TABLE IF NOT EXISTS project_environments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id TEXT NOT NULL REFERENCES projects(id),
name TEXT NOT NULL,
host TEXT NOT NULL,
port INTEGER DEFAULT 22,
username TEXT NOT NULL,
auth_type TEXT NOT NULL DEFAULT 'password',
auth_value TEXT,
is_installed INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(project_id, name)
);
CREATE INDEX IF NOT EXISTS idx_environments_project ON project_environments(project_id);
-- Индексы
CREATE INDEX IF NOT EXISTS idx_tasks_project_status ON tasks(project_id, status);
CREATE INDEX IF NOT EXISTS idx_decisions_project ON decisions(project_id);
@ -232,6 +250,19 @@ CREATE INDEX IF NOT EXISTS idx_agent_logs_project ON agent_logs(project_id, crea
CREATE INDEX IF NOT EXISTS idx_agent_logs_cost ON agent_logs(project_id, cost_usd);
CREATE INDEX IF NOT EXISTS idx_tickets_project ON support_tickets(project_id, status);
CREATE INDEX IF NOT EXISTS idx_tickets_client ON support_tickets(client_id);
-- Чат-сообщения (KIN-OBS-012)
CREATE TABLE IF NOT EXISTS chat_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id TEXT NOT NULL REFERENCES projects(id),
role TEXT NOT NULL,
content TEXT NOT NULL,
message_type TEXT DEFAULT 'text',
task_id TEXT REFERENCES tasks(id),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_chat_messages_project ON chat_messages(project_id, created_at);
"""
@ -333,6 +364,26 @@ def _migrate(conn: sqlite3.Connection):
existing_tables = {r[0] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()}
if "project_environments" not in existing_tables:
conn.executescript("""
CREATE TABLE IF NOT EXISTS project_environments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id TEXT NOT NULL REFERENCES projects(id),
name TEXT NOT NULL,
host TEXT NOT NULL,
port INTEGER DEFAULT 22,
username TEXT NOT NULL,
auth_type TEXT NOT NULL DEFAULT 'password',
auth_value TEXT,
is_installed INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(project_id, name)
);
CREATE INDEX IF NOT EXISTS idx_environments_project ON project_environments(project_id);
""")
conn.commit()
if "project_phases" not in existing_tables:
conn.executescript("""
CREATE TABLE IF NOT EXISTS project_phases (
@ -441,6 +492,21 @@ def _migrate(conn: sqlite3.Connection):
PRAGMA foreign_keys=ON;
""")
if "chat_messages" not in existing_tables:
conn.executescript("""
CREATE TABLE IF NOT EXISTS chat_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id TEXT NOT NULL REFERENCES projects(id),
role TEXT NOT NULL,
content TEXT NOT NULL,
message_type TEXT DEFAULT 'text',
task_id TEXT REFERENCES tasks(id),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_chat_messages_project ON chat_messages(project_id, created_at);
""")
conn.commit()
# Rename legacy 'auto' → 'auto_complete' (KIN-063)
conn.execute(
"UPDATE projects SET execution_mode = 'auto_complete' WHERE execution_mode = 'auto'"

View file

@ -3,7 +3,9 @@ Kin — data access functions for all tables.
Pure functions: (conn, params) dict | list[dict]. No ORM, no classes.
"""
import base64
import json
import os
import sqlite3
from datetime import datetime
from typing import Any
@ -102,7 +104,8 @@ def get_project(conn: sqlite3.Connection, id: str) -> dict | None:
def delete_project(conn: sqlite3.Connection, id: str) -> None:
"""Delete a project and all its related data (modules, decisions, tasks, phases)."""
# Delete tables that have FK references to tasks BEFORE deleting tasks
for table in ("modules", "agent_logs", "decisions", "pipelines", "project_phases", "tasks"):
# project_environments must come before tasks (FK on project_id)
for table in ("modules", "agent_logs", "decisions", "pipelines", "project_phases", "project_environments", "chat_messages", "tasks"):
conn.execute(f"DELETE FROM {table} WHERE project_id = ?", (id,))
conn.execute("DELETE FROM projects WHERE id = ?", (id,))
conn.commit()
@ -700,3 +703,187 @@ def update_phase(conn: sqlite3.Connection, phase_id: int, **fields) -> dict:
conn.execute(f"UPDATE project_phases SET {sets} WHERE id = ?", vals)
conn.commit()
return get_phase(conn, phase_id)
# ---------------------------------------------------------------------------
# Project Environments (KIN-087)
# ---------------------------------------------------------------------------
def _get_fernet():
"""Get Fernet instance using KIN_SECRET_KEY env var.
Raises RuntimeError if KIN_SECRET_KEY is not set.
"""
key = os.environ.get("KIN_SECRET_KEY")
if not key:
raise RuntimeError(
"KIN_SECRET_KEY environment variable is not set. "
"Generate with: python -c \"from cryptography.fernet import Fernet; "
"print(Fernet.generate_key().decode())\""
)
from cryptography.fernet import Fernet
return Fernet(key.encode())
def _encrypt_auth(value: str) -> str:
"""Encrypt auth_value using Fernet (AES-128-CBC + HMAC-SHA256)."""
return _get_fernet().encrypt(value.encode()).decode()
def _decrypt_auth(
stored: str,
conn: sqlite3.Connection | None = None,
env_id: int | None = None,
) -> str:
"""Decrypt auth_value. Handles migration from legacy base64 obfuscation.
If stored value uses the old b64: prefix, decodes it and re-encrypts
in the DB (re-encrypt on read) if conn and env_id are provided.
"""
if not stored:
return stored
from cryptography.fernet import InvalidToken
try:
return _get_fernet().decrypt(stored.encode()).decode()
except (InvalidToken, Exception):
# Legacy b64: format — migrate on read
if stored.startswith("b64:"):
plaintext = base64.b64decode(stored[4:]).decode()
if conn is not None and env_id is not None:
new_encrypted = _encrypt_auth(plaintext)
conn.execute(
"UPDATE project_environments SET auth_value = ? WHERE id = ?",
(new_encrypted, env_id),
)
conn.commit()
return plaintext
return stored
def create_environment(
conn: sqlite3.Connection,
project_id: str,
name: str,
host: str,
username: str,
port: int = 22,
auth_type: str = "password",
auth_value: str | None = None,
is_installed: bool = False,
) -> dict:
"""Create a project environment. auth_value stored Fernet-encrypted; returned as None."""
obfuscated = _encrypt_auth(auth_value) if auth_value else None
cur = conn.execute(
"""INSERT INTO project_environments
(project_id, name, host, port, username, auth_type, auth_value, is_installed)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(project_id, name, host, port, username, auth_type, obfuscated, int(is_installed)),
)
conn.commit()
row = conn.execute(
"SELECT * FROM project_environments WHERE id = ?", (cur.lastrowid,)
).fetchone()
result = _row_to_dict(row)
result["auth_value"] = None # never expose in API responses
return result
def get_environment(conn: sqlite3.Connection, env_id: int) -> dict | None:
"""Get environment by id including raw obfuscated auth_value (for internal use)."""
row = conn.execute(
"SELECT * FROM project_environments WHERE id = ?", (env_id,)
).fetchone()
return _row_to_dict(row)
def list_environments(conn: sqlite3.Connection, project_id: str) -> list[dict]:
"""List all environments for a project. auth_value is always None in response."""
rows = conn.execute(
"SELECT * FROM project_environments WHERE project_id = ? ORDER BY created_at",
(project_id,),
).fetchall()
result = _rows_to_list(rows)
for env in result:
env["auth_value"] = None
return result
def update_environment(conn: sqlite3.Connection, env_id: int, **fields) -> dict:
"""Update environment fields. Auto-sets updated_at. Returns record with auth_value=None."""
if not fields:
result = get_environment(conn, env_id)
if result:
result["auth_value"] = None
return result
if "auth_value" in fields and fields["auth_value"]:
fields["auth_value"] = _encrypt_auth(fields["auth_value"])
elif "auth_value" in fields:
del fields["auth_value"] # empty/None = don't update auth_value
fields["updated_at"] = datetime.now().isoformat()
sets = ", ".join(f"{k} = ?" for k in fields)
vals = list(fields.values()) + [env_id]
conn.execute(f"UPDATE project_environments SET {sets} WHERE id = ?", vals)
conn.commit()
result = get_environment(conn, env_id)
if result:
result["auth_value"] = None
return result
def delete_environment(conn: sqlite3.Connection, env_id: int) -> bool:
"""Delete environment by id. Returns True if deleted, False if not found."""
cur = conn.execute(
"DELETE FROM project_environments WHERE id = ?", (env_id,)
)
conn.commit()
return cur.rowcount > 0
# ---------------------------------------------------------------------------
# Chat Messages (KIN-OBS-012)
# ---------------------------------------------------------------------------
def add_chat_message(
conn: sqlite3.Connection,
project_id: str,
role: str,
content: str,
message_type: str = "text",
task_id: str | None = None,
) -> dict:
"""Add a chat message and return it as dict.
role: 'user' | 'assistant' | 'system'
message_type: 'text' | 'task_created' | 'error'
task_id: set for message_type='task_created' to link to the created task.
"""
cur = conn.execute(
"""INSERT INTO chat_messages (project_id, role, content, message_type, task_id)
VALUES (?, ?, ?, ?, ?)""",
(project_id, role, content, message_type, task_id),
)
conn.commit()
row = conn.execute(
"SELECT * FROM chat_messages WHERE id = ?", (cur.lastrowid,)
).fetchone()
return _row_to_dict(row)
def get_chat_messages(
conn: sqlite3.Connection,
project_id: str,
limit: int = 50,
before_id: int | None = None,
) -> list[dict]:
"""Get chat messages for a project in chronological order (oldest first).
before_id: pagination cursor return messages with id < before_id.
"""
query = "SELECT * FROM chat_messages WHERE project_id = ?"
params: list = [project_id]
if before_id is not None:
query += " AND id < ?"
params.append(before_id)
query += " ORDER BY created_at ASC, id ASC LIMIT ?"
params.append(limit)
return _rows_to_list(conn.execute(query, params).fetchall())