Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
04fee20
feat(feedback): answer_log table and thumbs up/down recording
northdpole Oct 19, 2026
27ceb40
feat(mcp): ask returns an answer_id; add rate_answer tool for thumbs …
northdpole Oct 19, 2026
31ee791
feat(chat): log chat answers, return answer_id, POST /rest/v1/answer_…
northdpole Oct 19, 2026
4f23436
feat(feedback): log chat answers only when a deployment opts in (ANSW…
northdpole Oct 19, 2026
1b9fef6
feat(chat): thumbs up/down under answers, with a storage notice when …
northdpole Oct 19, 2026
4eac3dc
feat(chat): hide the 'not stored' privacy paragraph when answers are …
northdpole Oct 19, 2026
a47c6c9
test(migrations): fail when the alembic chain has more than one head
northdpole Oct 19, 2026
0bc7f9f
test(feedback): restore NO_LOAD_GRAPH_DB after each feedback test
northdpole Oct 20, 2026
f645851
chore(staging): script that compares staging code with the GitHub tip
northdpole Oct 21, 2026
29db92f
chore(staging): hotpatch image builds the frontend bundle instead of …
northdpole Oct 22, 2026
dd9732c
feat(comprehension): OWASP_CX_PICK - a cheap model picks the project …
northdpole Oct 22, 2026
2af6318
feat(comprehension): OWASP_CX_COMPLETE2 second pass for unanswered pa…
northdpole Oct 22, 2026
688e5cc
feat(comprehension): OWASP_CX_NEG tests every option for NOT/except q…
northdpole Oct 22, 2026
5c5b40b
Revert "feat(comprehension): talk catalogue loader (title/speaker/dat…
northdpole Oct 22, 2026
b74b573
fix(comprehension): a failed model call returns a temporary-problem m…
northdpole Oct 22, 2026
7196bf0
feat(comprehension): OWASP_CX_PREFIXCACHE puts the project text befor…
northdpole Oct 23, 2026
64f74b2
feat(comprehension): clone-only/wiki/cap options for raw ingest; OWAS…
northdpole Oct 23, 2026
79ff506
feat(auth): two roles (user, admin) shared by the web chatbot and MCP…
northdpole Oct 23, 2026
1ccacaf
feat(chat): chatbot and MCP ask share one answer entry point (history…
northdpole Oct 23, 2026
98bcd42
feat(llm): pin litellm==1.103.4 (2026 advisories, malicious 1.82.7/1.…
northdpole Oct 24, 2026
457121c
chore(deps): track the latest litellm (>=1.83.7, the fixed release); …
northdpole Oct 24, 2026
1492032
fix(chat): follow-ups are rewritten into standalone questions (histor…
northdpole Oct 24, 2026
b545ddb
test(chat): scripted multi-turn conversations in CI and a live multi-…
northdpole Oct 24, 2026
bc0e9a7
feat(chat): earlier turns reach the answer call as native multi-turn …
northdpole Oct 24, 2026
9e8c38c
fix(chat): [n] citation markers match the numbered, clickable source …
northdpole Oct 24, 2026
9d22c24
feat(chat): new prompt is a fresh chat; 'Continue conversation' check…
northdpole Oct 24, 2026
7f94586
feat(comprehension): admin-editable project authority weights and TOP…
northdpole Oct 25, 2026
f0d1edf
fix(comprehension): raise hnsw.ef_search for wide dense queries (inde…
northdpole Oct 25, 2026
a1f08ca
feat(comprehension): weighted-up projects always contribute their bes…
northdpole Oct 25, 2026
99b19ba
feat(comprehension): authority above 1 features a project's best matc…
northdpole Oct 25, 2026
7335acc
fix(admin): apostrophe broke the Authority tab description string
northdpole Oct 25, 2026
ec70f2f
docs(oie): document max_repos semantics (0 = no cap, omitted = capped…
northdpole Oct 25, 2026
f2d8fec
feat(chat): move 'Continue conversation' next to Ask; send only the l…
northdpole Oct 25, 2026
284bdbd
feat(chat): attach the closest CRE to every evidence answer (CRE-firs…
northdpole Oct 25, 2026
0fa1a94
fix(chat): strip copied markdown links and [[n]] markers from answers…
northdpole Oct 25, 2026
107e67d
fix(chat): strip links whose URLs contain spaces; percent-encode spac…
northdpole Oct 25, 2026
10a238c
fix(deps): keep litellm below 1.107 (reported malicious release)
northdpole Oct 26, 2026
00c4e47
fix(chat): CRE references link to /cre/<id>; list near-tie CREs; show…
northdpole Oct 26, 2026
cd19478
fix(chat): CRE names that share a keyword with the question rank into…
northdpole Oct 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,21 @@ OWASP_WARMUP=1

# Remote MCP server: how many questions may be answered at the same time (each in its own thread).
OPENCRE_MCP_ASK_CONCURRENCY=8

# --- Accounts and roles (web chatbot and MCP share one model; see docs/roles.md) ---
# Two roles: "user" = any account on an allowed domain; "admin" = the named accounts below.
# Leave ADMIN_EMAILS unset for the old behaviour (LOGIN_ALLOWED_EMAILS is the one allowlist, all admins).
# ADMIN_EMAILS=alice@owasp.org,bob@owasp.org
# LOGIN_ALLOWED_DOMAINS=owasp.org

# --- Per-account question limits (chatbot + MCP ask; shared through Redis) ---
# Defaults per account: 5 / minute, 40 / hour, 150 / day; admins x4. Uncomment to change.
# RATE_LIMIT_PER_MINUTE=5
# RATE_LIMIT_PER_HOUR=40
# RATE_LIMIT_PER_DAY=150
# RATE_LIMIT_ADMIN_MULTIPLIER=4
# RATE_LIMIT_EXEMPT_EMAILS=evalbot@owasp.org
# RATE_LIMIT_ENABLED=0 # switch limits off

# Warm each web worker in the background at start so the first chat is not slow (the MCP server always does).
# OWASP_WARMUP_WEB=1
4 changes: 4 additions & 0 deletions application/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,8 @@ def create_app(mode: str = "production", conf: any = None) -> Any:
compress.init_app(app)
cache.init_app(app)

if mode != "test":
from application.utils.comprehension.shared import start_background_warm_up

start_background_warm_up(app) # no-op unless OWASP_WARMUP_WEB=1
return app
23 changes: 23 additions & 0 deletions application/database/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,29 @@ class AdminRuntimeSettingAudit(BaseModel): # type: ignore
__table_args__ = (sqla.Index("ix_admin_runtime_setting_audit_key", "key"),)


class AnswerLog(BaseModel): # type: ignore
"""Every answer the system gives, with an optional thumbs up/down from the asker."""

__tablename__ = "answer_log"
id = sqla.Column(sqla.String, primary_key=True, default=generate_uuid)
created_at = sqla.Column(sqla.DateTime, nullable=False)
source = sqla.Column(sqla.String, nullable=False) # "mcp" | "chat"
question = sqla.Column(sqla.Text, nullable=False)
answer = sqla.Column(sqla.Text, nullable=True)
citations = sqla.Column(sqla.Text, nullable=True) # JSON list of urls
channel = sqla.Column(sqla.String, nullable=True)
flags = sqla.Column(sqla.String, nullable=True) # active OWASP_CX_* flags
latency_ms = sqla.Column(sqla.Integer, nullable=True)
rating = sqla.Column(sqla.String, nullable=True) # "up" | "down"
comment = sqla.Column(sqla.Text, nullable=True)
rated_at = sqla.Column(sqla.DateTime, nullable=True)

__table_args__ = (
sqla.Index("ix_answer_log_created_at", "created_at"),
sqla.Index("ix_answer_log_rating", "rating"),
)


class User(BaseModel): # type: ignore
"""Persisted account identity for OIDC login (issue #586, RFC #876 TODO 1).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ export interface DocumentNode {
const linkTypesToNest = [TYPE_IS_PART_OF, TYPE_RELATED, TYPE_AUTOLINKED_TO];
const linkTypesExcludedInNesting = [TYPE_CONTAINS];
const linkTypesExcludedWhenNestingRelatedTo = [TYPE_RELATED, TYPE_IS_PART_OF, TYPE_CONTAINS];
const linkDisplayOrder = [TYPE_LINKED_TO, TYPE_AUTOLINKED_TO, TYPE_CONTAINS, TYPE_IS_PART_OF, TYPE_RELATED];
// "Related" is more useful than the hierarchy, so it comes before "child of".
const linkDisplayOrder = [TYPE_LINKED_TO, TYPE_AUTOLINKED_TO, TYPE_CONTAINS, TYPE_RELATED, TYPE_IS_PART_OF];

export const DocumentNode: FunctionComponent<DocumentNode> = ({
node,
Expand Down
32 changes: 32 additions & 0 deletions application/frontend/src/pages/Admin/Admin.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,38 @@ describe('Admin', () => {
expect(JSON.parse(put!.init.body)).toEqual({ interval_minutes: 30, enabled: true });
});

it('edits project authority weights and saves only the changed ones', async () => {
loggedIn();
const calls: { url: string; init?: any }[] = [];
(global as any).fetch = jest.fn((url: string, init?: any) => {
calls.push({ url: String(url), init });
if (String(url).endsWith('/admin/authority') && init?.method === 'PUT') {
return jsonRes({ weights: { 'www-project-ai-exchange': 1.2 } });
}
if (String(url).endsWith('/admin/authority')) {
return jsonRes({
projects: [
{ slug: 'www-project-ai-exchange', name: 'ai exchange', weight: 1 },
{ slug: 'AISVS', name: 'aisvs', weight: 1 },
],
});
}
return jsonRes({});
});
const { getByText, findByLabelText, findByText } = render(
<MemoryRouter>
<Admin />
</MemoryRouter>
);
fireEvent.click(getByText('Authority'));
const input = (await findByLabelText('www-project-ai-exchange weight')) as HTMLInputElement;
fireEvent.change(input, { target: { value: '1.2' } });
fireEvent.click(getByText(/^Save/));
expect(await findByText(/1 project weight\(s\) saved/)).toBeTruthy();
const put = calls.find((c) => c.init?.method === 'PUT');
expect(JSON.parse(put!.init.body)).toEqual({ weights: { 'www-project-ai-exchange': 1.2 } });
});

it('loads import runs and links to MyOpenCRE', async () => {
loggedIn();
(global as any).fetch = jest.fn((url: string) => {
Expand Down
110 changes: 110 additions & 0 deletions application/frontend/src/pages/Admin/Admin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ type Tab =
| 'pipeline'
| 'oie'
| 'schedule'
| 'authority'
| 'graph'
| 'workbench'
| 'myopencre'
Expand All @@ -23,6 +24,10 @@ const TAB_META: Record<Tab, { label: string; sub: string }> = {
config: { label: 'Config values', sub: 'Process env vs .env — admin only.' },
pipeline: { label: 'Pipeline', sub: 'Jobs, stage logs, ingest.' },
oie: { label: 'OIE queue', sub: 'RQ / OIE worker status.' },
authority: {
label: 'Authority',
sub: 'Feature or soften projects. 1 = neutral; above 1 = always list the closest passage among the references; below 1 = rank lower; 0 = hidden.',
},
schedule: { label: 'Schedules', sub: 'How often the ingestion jobs run — change or switch off.' },
graph: { label: 'Review', sub: 'Staged links — expand section text, accept/reject.' },
workbench: { label: 'Workbench', sub: 'Accepted targets and re-ingest.' },
Expand Down Expand Up @@ -182,6 +187,7 @@ export const Admin = () => {
{ id: 'pipeline', label: 'Pipeline', group: 'Ops' },
{ id: 'oie', label: 'OIE queue', group: 'Ops' },
{ id: 'schedule', label: 'Schedules', group: 'Ops' },
{ id: 'authority', label: 'Authority', group: 'Ops' },
{ id: 'graph', label: 'Review', group: 'Ops' },
{ id: 'workbench', label: 'Workbench', group: 'Ops' },
{ id: 'myopencre', label: 'MyOpenCRE', group: 'Apps' },
Expand Down Expand Up @@ -234,6 +240,7 @@ export const Admin = () => {
{tab === 'pipeline' && <PipelineTab origin={origin} />}
{tab === 'oie' && <OieQueueTab origin={origin} />}
{tab === 'schedule' && <ScheduleTab origin={origin} />}
{tab === 'authority' && <AuthorityTab origin={origin} />}
{tab === 'graph' && <GraphManagementTab origin={origin} />}
{tab === 'workbench' && <WorkbenchTab origin={origin} />}
{tab === 'myopencre' && (
Expand Down Expand Up @@ -1074,6 +1081,109 @@ type ScheduleJob = {
last_run: { status?: string; started_at?: string } | null;
};

type AuthorityRow = { slug: string; name: string; weight: number };

function AuthorityTab({ origin }: { origin: string }) {
const [rows, setRows] = useState<AuthorityRow[]>([]);
const [draft, setDraft] = useState<Record<string, string>>({});
const [filter, setFilter] = useState('');
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [busy, setBusy] = useState(false);

const load = useCallback(() => {
fetch(`${origin}/admin/authority`)
.then(async (res) => {
const body = await readJson(res);
if (!res.ok) throw new Error(apiError(body, res));
const list: AuthorityRow[] = body.projects || [];
setRows(list);
setDraft(Object.fromEntries(list.map((r) => [r.slug, String(r.weight)])));
setError(null);
})
.catch((err) => setError(String(err)));
}, [origin]);

useEffect(() => {
load();
}, [load]);

const changed = rows.filter((r) => Number(draft[r.slug]) !== r.weight);

const save = async () => {
setBusy(true);
setNotice(null);
setError(null);
try {
const weights = Object.fromEntries(changed.map((r) => [r.slug, Number(draft[r.slug])]));
const res = await fetch(`${origin}/admin/authority`, {
method: 'PUT',
headers: mutHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ weights }),
});
const body = await readJson(res);
if (!res.ok) throw new Error(apiError(body, res));
setNotice(`${changed.length} project weight(s) saved; effective within 30 seconds`);
load();
} catch (err) {
setError(String(err));
} finally {
setBusy(false);
}
};

const shown = rows
.filter((r) => !filter || r.slug.toLowerCase().includes(filter.toLowerCase()))
.sort((a, b) => Number(b.weight !== 1) - Number(a.weight !== 1) || a.slug.localeCompare(b.slug));

return (
<div>
{error ? <Message negative content={error} /> : null}
{notice ? <Message positive content={notice} /> : null}
<p>
Above 1 never changes the answer itself: the project&apos;s closest passage is added to the references
when it is reasonably relevant (1.15 is strict, 1.3 is looser). Below 1 pushes a project down in the
ranking and 0 hides it. Questions that name a project are not affected.
</p>
<input
aria-label="filter projects"
placeholder="Filter projects"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>{' '}
<Button primary size="small" disabled={!changed.length || busy} onClick={save}>
Save {changed.length ? `(${changed.length})` : ''}
</Button>
<table className="admin-table" data-testid="authority-table">
<thead>
<tr>
<th>project</th>
<th>weight</th>
</tr>
</thead>
<tbody>
{shown.map((r) => (
<tr key={r.slug}>
<td className="mono">{r.slug}</td>
<td>
<input
type="number"
step={0.05}
min={0}
max={3}
aria-label={`${r.slug} weight`}
value={draft[r.slug] ?? ''}
onChange={(e) => setDraft({ ...draft, [r.slug]: e.target.value })}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

function ScheduleTab({ origin }: { origin: string }) {
const [jobs, setJobs] = useState<ScheduleJob[]>([]);
const [draft, setDraft] = useState<Record<string, { interval: string; enabled: boolean }>>({});
Expand Down
31 changes: 26 additions & 5 deletions application/frontend/src/pages/chatbot/chatbot.scss
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,9 @@ h1.ui.header.chatbot-title {
margin-bottom: 1rem !important;
}

.chatbot-toolbar {
display: flex;
justify-content: center;
margin: 0 auto 0.5rem;
max-width: 960px;
.chat-continue {
margin: 0.25rem 0 0.5rem;
text-align: left;
}

/* Tablet & below */
Expand Down Expand Up @@ -477,3 +475,26 @@ h1.ui.header.chatbot-title {
transform: translateX(-50%) translateY(0);
}
}

.rate-answer {
display: flex;
gap: 0.25rem;
margin-top: 0.5rem;

.rate-btn {
background: none;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
padding: 0.2rem 0.4rem;

&:hover {
border-color: #c8cdd3;
}

&.is-selected {
background: #e8f0fe;
border-color: #7ba3f0;
}
}
}
Loading
Loading