chore: commit local changes
This commit is contained in:
1 parent
c1336ad5b3
commit
e6e0832c8a
15 files changed
+291
-24
No files matched your search
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, MessageSquareIcon, SearchIcon, UserIcon } from "lucide-react";
|
||||
import { ArrowDownWideNarrow, Loader2, MessageSquareIcon, SearchIcon, UserIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -83,6 +83,8 @@ function UserInfoPanel({ user }: { user: UserListItem }) {
|
||||
<Badge variant="secondary">普通用户</Badge>
|
||||
)}
|
||||
</dd>
|
||||
<dt className="text-muted-foreground font-medium">问答次数</dt>
|
||||
<dd className="tabular-nums">{user.qa_count}</dd>
|
||||
<dt className="text-muted-foreground font-medium">Skills</dt>
|
||||
<dd className="tabular-nums">{user.skill_count}</dd>
|
||||
<dt className="text-muted-foreground font-medium">Agents</dt>
|
||||
@@ -300,21 +302,29 @@ export function AdminUserHub() {
|
||||
const { data: users, isLoading: usersLoading } = useAllUsers(true);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||
const [userQuery, setUserQuery] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<"info" | "audit" | "memory">("info");
|
||||
// 活动审计 放到前面 → 默认进入审计标签页。
|
||||
const [activeTab, setActiveTab] = useState<"audit" | "info" | "memory">("audit");
|
||||
// 是否按问答次数(降序)排序用户列表。
|
||||
const [sortByQa, setSortByQa] = useState(false);
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
const needle = userQuery.trim().toLowerCase();
|
||||
const list = users ?? [];
|
||||
return needle
|
||||
? list.filter((u) => u.email.toLowerCase().includes(needle))
|
||||
: list;
|
||||
}, [users, userQuery]);
|
||||
let list = users ?? [];
|
||||
if (needle) {
|
||||
list = list.filter((u) => u.email.toLowerCase().includes(needle));
|
||||
}
|
||||
if (sortByQa) {
|
||||
// Copy before sorting so we never mutate the query cache array.
|
||||
list = [...list].sort((a, b) => (b.qa_count ?? 0) - (a.qa_count ?? 0));
|
||||
}
|
||||
return list;
|
||||
}, [users, userQuery, sortByQa]);
|
||||
|
||||
const selectedUser = users?.find((u) => u.id === selectedUserId) ?? null;
|
||||
|
||||
function selectUser(id: string) {
|
||||
setSelectedUserId(id);
|
||||
setActiveTab("info");
|
||||
setActiveTab("audit");
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -338,6 +348,20 @@ export function AdminUserHub() {
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSortByQa((v) => !v)}
|
||||
className={cn(
|
||||
"mx-2 mb-1 flex items-center justify-center gap-1 rounded-md border px-2 py-1 text-xs",
|
||||
sortByQa
|
||||
? "border-primary/40 bg-primary/5 text-primary"
|
||||
: "text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
title="按问答次数从高到低排序"
|
||||
>
|
||||
<ArrowDownWideNarrow className="size-3.5" />
|
||||
{sortByQa ? "按问答次数排序中" : "按问答次数排序"}
|
||||
</button>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
{usersLoading && (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8 text-sm">
|
||||
@@ -369,6 +393,12 @@ export function AdminUserHub() {
|
||||
管理员
|
||||
</Badge>
|
||||
)}
|
||||
<span
|
||||
className="text-muted-foreground shrink-0 tabular-nums text-xs"
|
||||
title="问答次数"
|
||||
>
|
||||
{u.qa_count ?? 0}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -388,8 +418,8 @@ export function AdminUserHub() {
|
||||
>
|
||||
<div className="border-b px-4 pt-3 shrink-0">
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="info">用户信息</TabsTrigger>
|
||||
<TabsTrigger value="audit">活动审计</TabsTrigger>
|
||||
<TabsTrigger value="info">用户信息</TabsTrigger>
|
||||
<TabsTrigger value="memory">记忆管理</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface UserListItem {
|
||||
system_role: "admin" | "user";
|
||||
skill_count: number;
|
||||
agent_count: number;
|
||||
/** 问答次数:用户提问的次数(一次提问算一次)。 */
|
||||
qa_count: number;
|
||||
}
|
||||
|
||||
export async function listAllUsers(): Promise<UserListItem[]> {
|
||||
|
||||
@@ -310,7 +310,7 @@ const errorToastDarkOptions = {
|
||||
<label className="text-muted-foreground text-xs dark:text-white">用户名</label>
|
||||
<Input
|
||||
value={user}
|
||||
placeholder="按用户名匹配(@ 前部分)"
|
||||
placeholder="按用户名模糊搜索(@ 前部分)"
|
||||
onChange={(e) => {
|
||||
setUser(e.target.value);
|
||||
setPage(0);
|
||||
|
||||
@@ -71,6 +71,14 @@ class LocalAuthProvider(AuthProvider):
|
||||
"""
|
||||
return await self._repo.find_user_by_email_local_part(local_part)
|
||||
|
||||
async def find_users_by_email_local_part_like(self, substring: str, *, limit: int = 500) -> list[User]:
|
||||
"""Fuzzy-match users by a substring of their email local part.
|
||||
|
||||
Backs the admin LLM-metrics username filter — returns every account
|
||||
whose username contains ``substring`` (case-insensitive).
|
||||
"""
|
||||
return await self._repo.find_users_by_email_local_part_like(substring, limit=limit)
|
||||
|
||||
async def create_user(self, email: str, password: str | None = None, system_role: str = "user", needs_setup: bool = False) -> User:
|
||||
"""Create a new local user.
|
||||
|
||||
|
||||
@@ -81,6 +81,24 @@ class UserRepository(ABC):
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def find_users_by_email_local_part_like(self, substring: str, *, limit: int = 500) -> list["User"]:
|
||||
"""Fuzzy-match users whose email local part *contains* ``substring``.
|
||||
|
||||
Backs the admin LLM-metrics username filter (按用户名模糊搜索). Unlike
|
||||
:meth:`find_user_by_email_local_part` (exact prefix → single account),
|
||||
this returns every matching account. Case-insensitive substring match
|
||||
against the part before ``@``. Non-overriding repositories return an
|
||||
empty list.
|
||||
|
||||
Args:
|
||||
substring: Case-insensitive substring to match within the local part.
|
||||
limit: Maximum number of users to return.
|
||||
|
||||
Returns:
|
||||
Matching users (may be empty).
|
||||
"""
|
||||
return []
|
||||
|
||||
@abstractmethod
|
||||
async def update_user(self, user: User) -> User:
|
||||
"""Update an existing user.
|
||||
|
||||
@@ -109,6 +109,33 @@ class SQLiteUserRepository(UserRepository):
|
||||
row = result.scalar_one_or_none()
|
||||
return self._row_to_user(row) if row is not None else None
|
||||
|
||||
async def find_users_by_email_local_part_like(self, substring: str, *, limit: int = 500) -> list[User]:
|
||||
term = (substring or "").strip().lower()
|
||||
if not term:
|
||||
return []
|
||||
# Escape SQL LIKE wildcards so a literal ``_``/``%`` in the query does
|
||||
# not turn into a wildcard. ``\`` is the chosen escape char.
|
||||
escaped = term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
pattern = f"%{escaped}%"
|
||||
# Prefilter in SQL on the whole email (portable across sqlite/postgres/
|
||||
# mysql), then keep only rows whose *local part* contains the term so a
|
||||
# domain-only hit (e.g. searching "gmail") doesn't pull unrelated users.
|
||||
stmt = (
|
||||
select(UserRow)
|
||||
.where(func.lower(UserRow.email).like(pattern, escape="\\"))
|
||||
.order_by(UserRow.created_at.asc())
|
||||
.limit(max(1, limit))
|
||||
)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
rows = result.scalars().all()
|
||||
out: list[User] = []
|
||||
for row in rows:
|
||||
local = (row.email or "").split("@", 1)[0].lower()
|
||||
if term in local:
|
||||
out.append(self._row_to_user(row))
|
||||
return out
|
||||
|
||||
async def update_user(self, user: User) -> User:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(UserRow, str(user.id))
|
||||
|
||||
@@ -22,7 +22,7 @@ from app.gateway.auth import (
|
||||
from app.gateway.auth.config import get_auth_config
|
||||
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
|
||||
from app.gateway.csrf_middleware import is_secure_request
|
||||
from app.gateway.deps import get_agent_store, get_current_user_from_request, get_local_provider, get_skill_store
|
||||
from app.gateway.deps import get_agent_store, get_current_user_from_request, get_local_provider, get_run_store, get_skill_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -749,6 +749,7 @@ class UserListItem(BaseModel):
|
||||
system_role: Literal["admin", "user"]
|
||||
skill_count: int
|
||||
agent_count: int
|
||||
qa_count: int = 0 # 问答次数: number of runs (one run = one question)
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[UserListItem])
|
||||
@@ -757,11 +758,19 @@ async def list_all_users(
|
||||
skill_store: SkillStore = Depends(get_skill_store),
|
||||
agent_store: AgentStore = Depends(get_agent_store),
|
||||
):
|
||||
"""List all users with their skill and agent counts. Admin only."""
|
||||
"""List all users with their skill, agent and Q&A counts. Admin only."""
|
||||
user = await get_current_user_from_request(request)
|
||||
if user.system_role != "admin":
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
users = await get_local_provider().list_users()
|
||||
# One GROUP BY query for everyone's 问答次数, so the list stays a single
|
||||
# aggregate read instead of a per-user count. Best-effort: if the run store
|
||||
# is unavailable the column simply renders as 0 rather than failing the list.
|
||||
try:
|
||||
qa_counts = await get_run_store(request).count_by_user()
|
||||
except Exception:
|
||||
logger.warning("Failed to load per-user run counts for user list", exc_info=True)
|
||||
qa_counts = {}
|
||||
result = []
|
||||
for u in users:
|
||||
uid = str(u.id)
|
||||
@@ -772,6 +781,7 @@ async def list_all_users(
|
||||
system_role=u.system_role,
|
||||
skill_count=await skill_store.count_owned(uid),
|
||||
agent_count=await agent_store.count_owned(uid),
|
||||
qa_count=qa_counts.get(uid, 0),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -121,6 +121,26 @@ async def _resolve_user_name_to_id(name: str | None) -> str | None:
|
||||
return str(user.id) if user is not None else None
|
||||
|
||||
|
||||
async def _resolve_user_names_to_ids(name: str | None) -> list[str]:
|
||||
"""Fuzzy-resolve a username fragment to every matching account's UUID.
|
||||
|
||||
Powers the 按用户名模糊搜索 filter: typing part of a username matches all
|
||||
users whose email local part contains it. Returns an empty list when the
|
||||
fragment matches nobody, so the caller can short-circuit to zero rows.
|
||||
"""
|
||||
if not name or not name.strip():
|
||||
return []
|
||||
try:
|
||||
provider = get_local_provider()
|
||||
except Exception:
|
||||
return []
|
||||
try:
|
||||
users = await provider.find_users_by_email_local_part_like(name.strip())
|
||||
except Exception:
|
||||
return []
|
||||
return [str(u.id) for u in users]
|
||||
|
||||
|
||||
async def _attach_usernames(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Resolve each row's ``user_id`` UUID to a friendly ``user_name``.
|
||||
|
||||
@@ -161,9 +181,11 @@ def _build_query(
|
||||
until: str | None,
|
||||
limit: int,
|
||||
offset: int,
|
||||
user_ids: list[str] | None = None,
|
||||
) -> LlmMetricsQuery:
|
||||
return LlmMetricsQuery(
|
||||
user_id=user_id or None,
|
||||
user_ids=user_ids or None,
|
||||
model_name=model_name or None,
|
||||
status=status or None,
|
||||
since=_parse_dt(since),
|
||||
@@ -187,14 +209,15 @@ async def list_llm_metrics(
|
||||
) -> LlmMetricsListResponse:
|
||||
await _require_admin(request)
|
||||
store = _require_store(request)
|
||||
user_ids: list[str] | None = None
|
||||
if not user_id and user_name:
|
||||
resolved = await _resolve_user_name_to_id(user_name)
|
||||
if resolved is None:
|
||||
# Username unknown → no rows; short-circuit instead of a global scan.
|
||||
user_ids = await _resolve_user_names_to_ids(user_name)
|
||||
if not user_ids:
|
||||
# No username matches → no rows; short-circuit the global scan.
|
||||
return LlmMetricsListResponse(items=[], total=0, limit=limit, offset=offset)
|
||||
user_id = resolved
|
||||
query = _build_query(
|
||||
user_id=user_id,
|
||||
user_ids=user_ids,
|
||||
model_name=model_name,
|
||||
status=status,
|
||||
since=since,
|
||||
@@ -236,14 +259,13 @@ async def export_llm_metrics(
|
||||
"""Stream the full filtered metric set as an ``.xlsx`` file."""
|
||||
await _require_admin(request)
|
||||
store = _require_store(request)
|
||||
user_ids: list[str] | None = None
|
||||
if not user_id and user_name:
|
||||
resolved = await _resolve_user_name_to_id(user_name)
|
||||
if resolved is None:
|
||||
# No matching user — return an empty xlsx so the user still gets a
|
||||
# file (matching the list view's empty state).
|
||||
user_id = "__no_such_user__"
|
||||
else:
|
||||
user_id = resolved
|
||||
user_ids = await _resolve_user_names_to_ids(user_name)
|
||||
if not user_ids:
|
||||
# No matching user — sentinel id that matches nothing, so the export
|
||||
# still produces an empty xlsx (matching the list view's empty state).
|
||||
user_ids = ["__no_such_user__"]
|
||||
|
||||
try:
|
||||
from openpyxl import Workbook
|
||||
@@ -252,6 +274,7 @@ async def export_llm_metrics(
|
||||
|
||||
query = _build_query(
|
||||
user_id=user_id,
|
||||
user_ids=user_ids,
|
||||
model_name=model_name,
|
||||
status=status,
|
||||
since=since,
|
||||
|
||||
+3
@@ -34,6 +34,9 @@ class LlmMetricsQuery:
|
||||
"""Filter set for the admin list / export endpoints."""
|
||||
|
||||
user_id: str | None = None
|
||||
# Multiple user ids — set when a fuzzy username search resolves to several
|
||||
# accounts. Takes precedence over ``user_id`` when non-empty.
|
||||
user_ids: list[str] | None = None
|
||||
model_name: str | None = None
|
||||
status: str | None = None
|
||||
since: datetime | None = None
|
||||
|
||||
+4
-1
@@ -58,7 +58,9 @@ class SqlLlmMetricsStore(LlmMetricsStore):
|
||||
logger.warning("Failed to persist llm_call_metrics row %s", metric.id, exc_info=True)
|
||||
|
||||
def _apply_filters(self, stmt, query: LlmMetricsQuery):
|
||||
if query.user_id:
|
||||
if query.user_ids:
|
||||
stmt = stmt.where(LlmCallMetricRow.user_id.in_(query.user_ids))
|
||||
elif query.user_id:
|
||||
stmt = stmt.where(LlmCallMetricRow.user_id == query.user_id)
|
||||
if query.model_name:
|
||||
stmt = stmt.where(LlmCallMetricRow.model_name == query.model_name)
|
||||
@@ -92,6 +94,7 @@ class SqlLlmMetricsStore(LlmMetricsStore):
|
||||
while True:
|
||||
page_query = LlmMetricsQuery(
|
||||
user_id=query.user_id,
|
||||
user_ids=query.user_ids,
|
||||
model_name=query.model_name,
|
||||
status=query.status,
|
||||
since=query.since,
|
||||
|
||||
@@ -205,6 +205,21 @@ class RunRepository(RunStore):
|
||||
await session.execute(update(RunRow).where(RunRow.run_id == run_id).values(**values))
|
||||
await session.commit()
|
||||
|
||||
async def count_by_user(self) -> dict[str, int]:
|
||||
"""Return ``{user_id: run_count}`` via a single GROUP BY query.
|
||||
|
||||
Each run is one user question, so this is the source for the admin
|
||||
user center's 问答次数 column. Runs without a ``user_id`` are skipped.
|
||||
"""
|
||||
stmt = (
|
||||
select(RunRow.user_id, func.count().label("cnt"))
|
||||
.where(RunRow.user_id.is_not(None))
|
||||
.group_by(RunRow.user_id)
|
||||
)
|
||||
async with self._sf() as session:
|
||||
rows = (await session.execute(stmt)).all()
|
||||
return {r.user_id: r.cnt for r in rows}
|
||||
|
||||
async def aggregate_tokens_by_thread(self, thread_id: str) -> dict[str, Any]:
|
||||
"""Aggregate token usage via a single SQL GROUP BY query."""
|
||||
_completed = RunRow.status.in_(("success", "error"))
|
||||
|
||||
@@ -93,3 +93,12 @@ class RunStore(abc.ABC):
|
||||
by_caller ({lead_agent, subagent, middleware}).
|
||||
"""
|
||||
pass
|
||||
|
||||
async def count_by_user(self) -> dict[str, int]:
|
||||
"""Return ``{user_id: run_count}`` across all runs.
|
||||
|
||||
One run corresponds to one user submission, so this backs the admin
|
||||
user center's 问答次数 (Q&A count) column — each question counts once.
|
||||
Non-overriding stores return an empty mapping (treated as 0 per user).
|
||||
"""
|
||||
return {}
|
||||
+39
@@ -38,6 +38,11 @@ _DANGEROUS_URL_RE = re.compile(r"^\s*(javascript|vbscript|data:text/html)", re.I
|
||||
# Attributes carrying a URL we must scheme-check.
|
||||
_URL_ATTRS = frozenset({"href", "src", "action", "formaction", "xlink:href", "background", "poster"})
|
||||
|
||||
_LOCAL_DOC_LINK_RE = re.compile(
|
||||
r"^(?![a-z][a-z0-9+.-]*:)(?!//)(?P<path>[^#?]*\.(?:html?|md|markdown))(?P<query>\?[^#]*)?(?P<fragment>#.+)?$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def parse_frontmatter(text: str) -> tuple[dict[str, Any], str]:
|
||||
"""Split a ``---`` YAML frontmatter block from the body of a SKILL.md.
|
||||
@@ -225,6 +230,38 @@ def sanitize_html(html: str) -> tuple[str, list[str]]:
|
||||
return str(soup), removed
|
||||
|
||||
|
||||
def constrain_same_page_anchor_links(html: str) -> tuple[str, list[str]]:
|
||||
"""Keep generated HTML document jumps inside the current HTML file."""
|
||||
if not html or not isinstance(html, str):
|
||||
return "", []
|
||||
try:
|
||||
from bs4 import BeautifulSoup
|
||||
except Exception: # pragma: no cover - bs4 is a declared dependency
|
||||
logger.warning("BeautifulSoup unavailable; returning HTML links unconstrained")
|
||||
return html, []
|
||||
|
||||
changed: list[str] = []
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
for tag in soup.find_all("a"):
|
||||
href = tag.get("href")
|
||||
if not isinstance(href, str):
|
||||
continue
|
||||
value = href.strip()
|
||||
if not value or value.startswith("#"):
|
||||
continue
|
||||
match = _LOCAL_DOC_LINK_RE.match(value)
|
||||
if not match:
|
||||
continue
|
||||
fragment = match.group("fragment")
|
||||
if fragment:
|
||||
tag["href"] = fragment
|
||||
changed.append(f"{value}->{fragment}")
|
||||
else:
|
||||
del tag.attrs["href"]
|
||||
changed.append(f"{value}->removed")
|
||||
return str(soup), changed
|
||||
|
||||
|
||||
def validate_html(html: str) -> list[str]:
|
||||
"""Return a list of structural problems with ``html`` (empty == valid).
|
||||
|
||||
@@ -299,6 +336,7 @@ _GENERATION_TEMPLATE = """你是一个内网页面生成助手。请根据输入
|
||||
6. 不要包含 <script>、外链脚本、iframe、表单提交、on* 事件属性。
|
||||
7. 参考页面只用于风格和布局,页面内容必须来自“本次数据”,不要复用参考页旧数据。
|
||||
8. 页面使用中文,排版适合在浏览器中直接预览。
|
||||
9. HTML 内的目录/锚点跳转必须只跳转到当前这个 HTML 文件内部:只使用 href="#section-id",不要使用 href="index.html#section-id"、".md#..."、其它文件路径或任何跨文件锚点;目标区块必须有匹配的 id。
|
||||
"""
|
||||
|
||||
_REPAIR_TEMPLATE = """你是一个 HTML 修复助手。下面的 HTML 存在问题,请修复后重新输出完整页面。
|
||||
@@ -317,6 +355,7 @@ _REPAIR_TEMPLATE = """你是一个 HTML 修复助手。下面的 HTML 存在问
|
||||
2. fixed_html 必须是完整 HTML 文档,包含 <!doctype html>、html、head、body。
|
||||
3. 移除所有 <script>、iframe、外链脚本、on* 事件属性、表单提交。
|
||||
4. 保留原有内容与数据,只修复结构与安全问题。
|
||||
5. HTML 内的目录/锚点跳转必须只跳转到当前这个 HTML 文件内部:只使用 href="#section-id",不要链接到 .html/.md 文件作为锚点。
|
||||
"""
|
||||
|
||||
_STYLE_EXTRACT_TEMPLATE = """你是一个页面风格分析助手。请分析下面的 HTML 页面,提取可复用的风格与布局特征。
|
||||
|
||||
+57
@@ -53,8 +53,18 @@ _MARKDOWN_MIN_CHARS = 100
|
||||
_MARKDOWN_INSTRUCTION = (
|
||||
"\n\n【输出要求】请将本次任务的最终结果生成为一个 Markdown(.md) 格式的文件,"
|
||||
"保存到 /mnt/user-data/outputs 目录,并使用 present_files 展示该文件。"
|
||||
"Markdown 内的目录/锚点跳转必须只跳转到当前这个 Markdown 文件内部:"
|
||||
"只使用 [标题](#section-id),不要使用 [标题](xxx.md#section-id)、"
|
||||
"[标题](xxx.html#section-id) 或任何跨文件锚点。"
|
||||
)
|
||||
|
||||
_LOCAL_DOC_MARKDOWN_LINK_RE = re.compile(
|
||||
r"^(?![a-z][a-z0-9+.-]*:)(?!//)(?P<path>[^#?]*\.(?:html?|md|markdown))(?P<query>\?[^#]*)?(?P<fragment>#.+)?$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MARKDOWN_INLINE_LINK_RE = re.compile(r"(?<!!)\[([^\]]+)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
|
||||
_MARKDOWN_REFERENCE_LINK_RE = re.compile(r"^(\s*\[[^\]]+\]:\s*)(\S+)(.*)$", re.MULTILINE)
|
||||
|
||||
|
||||
class MarkdownRequirementError(Exception):
|
||||
"""Raised when a ``require_markdown`` task did not produce a valid .md file.
|
||||
@@ -262,6 +272,43 @@ def _has_valid_markdown_file(result_files: list[dict[str, Any]] | None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _normalize_same_page_markdown_target(target: str) -> str | None:
|
||||
value = (target or "").strip()
|
||||
if not value or value.startswith("#"):
|
||||
return value
|
||||
match = _LOCAL_DOC_MARKDOWN_LINK_RE.match(value)
|
||||
if not match:
|
||||
return value
|
||||
fragment = match.group("fragment")
|
||||
return fragment or None
|
||||
|
||||
|
||||
def _constrain_markdown_same_page_anchor_links(content: str) -> str:
|
||||
"""Keep generated Markdown document jumps inside the current Markdown file."""
|
||||
if not content or not isinstance(content, str):
|
||||
return content or ""
|
||||
|
||||
def replace_inline(match: re.Match[str]) -> str:
|
||||
label = match.group(1)
|
||||
target = match.group(2)
|
||||
normalized = _normalize_same_page_markdown_target(target)
|
||||
if normalized is None:
|
||||
return label
|
||||
return f"[{label}]({normalized})"
|
||||
|
||||
def replace_reference(match: re.Match[str]) -> str:
|
||||
prefix = match.group(1)
|
||||
target = match.group(2)
|
||||
suffix = match.group(3)
|
||||
normalized = _normalize_same_page_markdown_target(target)
|
||||
if normalized is None:
|
||||
return f"{prefix}#"
|
||||
return f"{prefix}{normalized}{suffix}"
|
||||
|
||||
content = _MARKDOWN_INLINE_LINK_RE.sub(replace_inline, content)
|
||||
return _MARKDOWN_REFERENCE_LINK_RE.sub(replace_reference, content)
|
||||
|
||||
|
||||
def _build_result(
|
||||
task: dict[str, Any],
|
||||
*,
|
||||
@@ -1169,6 +1216,7 @@ class ScheduledTaskService:
|
||||
|
||||
# 4) Sanitize + validate, with one repair pass on failure.
|
||||
html, _removed = html_page_utils.sanitize_html(html)
|
||||
html, _link_changes = html_page_utils.constrain_same_page_anchor_links(html)
|
||||
errors = html_page_utils.validate_html(html)
|
||||
if errors:
|
||||
logger.info("Generated HTML failed validation (%s); attempting repair", errors)
|
||||
@@ -1181,6 +1229,7 @@ class ScheduledTaskService:
|
||||
fixed = repaired.get("fixed_html")
|
||||
if isinstance(fixed, str) and "<" in fixed:
|
||||
fixed_clean, _ = html_page_utils.sanitize_html(fixed)
|
||||
fixed_clean, _ = html_page_utils.constrain_same_page_anchor_links(fixed_clean)
|
||||
if not html_page_utils.validate_html(fixed_clean):
|
||||
html = fixed_clean
|
||||
except Exception:
|
||||
@@ -1540,6 +1589,14 @@ class ScheduledTaskService:
|
||||
index += 1
|
||||
|
||||
shutil.copy2(path, candidate)
|
||||
if candidate.suffix.lower() in {".md", ".markdown"}:
|
||||
try:
|
||||
original = candidate.read_text(encoding="utf-8", errors="ignore")
|
||||
constrained = _constrain_markdown_same_page_anchor_links(original)
|
||||
if constrained != original:
|
||||
candidate.write_text(constrained, encoding="utf-8", newline="")
|
||||
except Exception:
|
||||
logger.debug("Failed to constrain scheduled markdown links in %s", candidate, exc_info=True)
|
||||
return candidate
|
||||
|
||||
def _build_file_result(self, *, virtual_path: str, source_path: Path, archive_path: Path) -> dict[str, Any]:
|
||||
|
||||
@@ -217,6 +217,29 @@ async def test_sql_store_filters_errors_only(sql_store):
|
||||
assert only_errors[0]["error_type"] == "APITimeoutError"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sql_store_filters_by_user_ids(sql_store):
|
||||
"""``user_ids`` (set by a fuzzy username search) matches several accounts."""
|
||||
base = datetime.now(UTC)
|
||||
for uid in ("alice", "alicia", "bob"):
|
||||
await sql_store.record(
|
||||
LlmMetricRecord(
|
||||
id=uuid.uuid4().hex,
|
||||
created_at=base,
|
||||
user_id=uid,
|
||||
duration_ms=1,
|
||||
status="success",
|
||||
)
|
||||
)
|
||||
# Fuzzy "ali" → {alice, alicia}; bob must be excluded.
|
||||
rows = await sql_store.list(LlmMetricsQuery(user_ids=["alice", "alicia"], limit=10))
|
||||
assert {r["user_id"] for r in rows} == {"alice", "alicia"}
|
||||
assert await sql_store.count(LlmMetricsQuery(user_ids=["alice", "alicia"])) == 2
|
||||
# user_ids takes precedence over a single user_id.
|
||||
rows = await sql_store.list(LlmMetricsQuery(user_id="bob", user_ids=["alice"], limit=10))
|
||||
assert {r["user_id"] for r in rows} == {"alice"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sql_store_iter_all_paginates(sql_store):
|
||||
base = datetime.now(UTC)
|
||||
|
||||
Reference in new issue
Block a user