Domain layer (zero framework imports): - domain/models.py: pure dataclasses (RuleDocument, RuleSearchResult, Conversation, ChatMessage, LLMResponse, ChatResult) - domain/ports.py: ABC interfaces (RuleRepository, LLMPort, ConversationStore, IssueTracker) - domain/services.py: ChatService orchestrates Q&A flow using only ports Outbound adapters (implement domain ports): - adapters/outbound/openrouter.py: OpenRouterLLM with persistent httpx client, robust JSON parsing, regex citation fallback - adapters/outbound/sqlite_convos.py: SQLiteConversationStore with async_sessionmaker, timezone-aware datetimes, cleanup support - adapters/outbound/gitea_issues.py: GiteaIssueTracker with markdown injection protection (fenced code blocks) - adapters/outbound/chroma_rules.py: ChromaRuleRepository with clamped similarity scores Inbound adapter: - adapters/inbound/api.py: thin FastAPI router with input validation (max_length constraints), proper HTTP status codes (503 for missing LLM) Configuration & wiring: - config/settings.py: Pydantic v2 SettingsConfigDict (no module-level singleton) - config/container.py: create_app() factory with lifespan-managed DI - main.py: minimal entry point Test infrastructure (90 tests, all passing): - tests/fakes/: in-memory implementations of all 4 ports - tests/domain/: 26 tests for models and ChatService - tests/adapters/: 64 tests for all adapters using fakes/mocks - No real API calls, no model downloads, no disk I/O in fast tests Also fixes: aiosqlite version constraint (>=0.19.0), adds hatch build targets for new package layout. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
168 lines
5.5 KiB
Python
168 lines
5.5 KiB
Python
"""Outbound adapter: Gitea issue tracker.
|
|
|
|
Implements the IssueTracker port using the Gitea REST API. A single
|
|
httpx.AsyncClient is shared across all calls (connection pool reuse); callers
|
|
must await close() when the adapter is no longer needed, typically in an
|
|
application lifespan handler.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Optional
|
|
|
|
import httpx
|
|
|
|
from domain.ports import IssueTracker
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_LABELS: list[str] = ["rules-gap", "ai-generated", "needs-review"]
|
|
_TITLE_MAX_QUESTION_LEN = 80
|
|
|
|
|
|
class GiteaIssueTracker(IssueTracker):
|
|
"""Outbound adapter that creates Gitea issues for unanswered questions.
|
|
|
|
Args:
|
|
token: Personal access token with issue-write permission.
|
|
owner: Repository owner (user or org name).
|
|
repo: Repository slug.
|
|
base_url: Base URL of the Gitea instance, e.g. "https://gitea.example.com".
|
|
Trailing slashes are stripped automatically.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
token: str,
|
|
owner: str,
|
|
repo: str,
|
|
base_url: str,
|
|
) -> None:
|
|
self._token = token
|
|
self._owner = owner
|
|
self._repo = repo
|
|
self._base_url = base_url.rstrip("/")
|
|
|
|
self._headers = {
|
|
"Authorization": f"token {token}",
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json",
|
|
}
|
|
self._client = httpx.AsyncClient(
|
|
headers=self._headers,
|
|
timeout=30.0,
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# IssueTracker port implementation
|
|
# ------------------------------------------------------------------
|
|
|
|
async def create_unanswered_issue(
|
|
self,
|
|
question: str,
|
|
user_id: str,
|
|
channel_id: str,
|
|
attempted_rules: list[str],
|
|
conversation_id: str,
|
|
) -> str:
|
|
"""Create a Gitea issue for a question the bot could not answer.
|
|
|
|
The question is embedded in a fenced code block to prevent markdown
|
|
injection — a user could craft a question that contains headers, links,
|
|
or other markdown syntax that would corrupt the issue layout.
|
|
|
|
Returns:
|
|
The HTML URL of the newly created issue.
|
|
|
|
Raises:
|
|
RuntimeError: If the Gitea API responds with a non-2xx status code.
|
|
"""
|
|
title = self._build_title(question)
|
|
body = self._build_body(
|
|
question, user_id, channel_id, attempted_rules, conversation_id
|
|
)
|
|
|
|
logger.info(
|
|
"Creating Gitea issue for unanswered question from user=%s channel=%s",
|
|
user_id,
|
|
channel_id,
|
|
)
|
|
|
|
payload: dict = {
|
|
"title": title,
|
|
"body": body,
|
|
"labels": _LABELS,
|
|
}
|
|
|
|
url = f"{self._base_url}/repos/{self._owner}/{self._repo}/issues"
|
|
response = await self._client.post(url, json=payload)
|
|
|
|
if response.status_code not in (200, 201):
|
|
error_detail = response.text
|
|
logger.error(
|
|
"Gitea API returned %s creating issue: %s",
|
|
response.status_code,
|
|
error_detail,
|
|
)
|
|
raise RuntimeError(
|
|
f"Gitea API error {response.status_code} creating issue: {error_detail}"
|
|
)
|
|
|
|
data = response.json()
|
|
html_url: str = data.get("html_url", "")
|
|
logger.info("Created Gitea issue: %s", html_url)
|
|
return html_url
|
|
|
|
# ------------------------------------------------------------------
|
|
# Lifecycle
|
|
# ------------------------------------------------------------------
|
|
|
|
async def close(self) -> None:
|
|
"""Release the underlying HTTP connection pool.
|
|
|
|
Call this in an application shutdown handler (e.g. FastAPI lifespan)
|
|
to avoid ResourceWarning on interpreter exit.
|
|
"""
|
|
await self._client.aclose()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Private helpers
|
|
# ------------------------------------------------------------------
|
|
|
|
@staticmethod
|
|
def _build_title(question: str) -> str:
|
|
"""Return a short, human-readable issue title."""
|
|
truncated = question[:_TITLE_MAX_QUESTION_LEN]
|
|
suffix = "..." if len(question) > _TITLE_MAX_QUESTION_LEN else ""
|
|
return f"Unanswered rules question: {truncated}{suffix}"
|
|
|
|
@staticmethod
|
|
def _build_body(
|
|
question: str,
|
|
user_id: str,
|
|
channel_id: str,
|
|
attempted_rules: list[str],
|
|
conversation_id: str,
|
|
) -> str:
|
|
"""Compose the Gitea issue body with all triage context.
|
|
|
|
The question is fenced so that markdown special characters in user
|
|
input cannot alter the issue structure.
|
|
"""
|
|
rules_list: str = ", ".join(attempted_rules) if attempted_rules else "None"
|
|
|
|
return (
|
|
"## Unanswered Question\n\n"
|
|
f"**User:** {user_id}\n\n"
|
|
f"**Channel:** {channel_id}\n\n"
|
|
f"**Conversation ID:** {conversation_id}\n\n"
|
|
"**Question:**\n"
|
|
f"```\n{question}\n```\n\n"
|
|
f"**Searched Rules:** {rules_list}\n\n"
|
|
"**Additional Context:**\n"
|
|
"This question was asked in Discord and the bot could not provide "
|
|
"a confident answer. The rules either don't cover this question or "
|
|
"the information was ambiguous.\n\n"
|
|
"---\n\n"
|
|
"*This issue was automatically created by the Strat-Chatbot.*"
|
|
)
|