kin: KIN-BIZ-006 Проверить промпт sysadmin.md на поддержку сценария env_scan
This commit is contained in:
parent
531275e4ce
commit
a58578bb9d
14 changed files with 1619 additions and 13 deletions
48
core/chat_intent.py
Normal file
48
core/chat_intent.py
Normal 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'
|
||||
66
core/db.py
66
core/db.py
|
|
@ -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'"
|
||||
|
|
|
|||
189
core/models.py
189
core/models.py
|
|
@ -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())
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue