-
Notifications
You must be signed in to change notification settings - Fork 15
Knowledge Assistant Developer Guide
How the assistant finds knowledge gaps and drafts articles. For what it does and how to use it, see Knowledge Assistant.
Two halves that answer different questions, sharing one engine:
| Half | Question | Where it lives |
|---|---|---|
| Gap finding | What is missing? | includes/knowledge/gap_analysis.php |
| Write-up | Is there enough here to write it? | includes/knowledge/writeup_ai.php |
And two entry points into the same write-up engine:
- Tickets owns the moment β an analyst has just solved something and it is fresh. Button in the reading pane.
- Knowledge owns the judgement β the clusters, the thresholds, the provider and key, the drafts.
That split is why the endpoint lives under api/knowledge/ even though one of its callers is the ticket inbox: writing an article is a Knowledge action wherever the button happens to be rendered, and requireModuleAccessJson('knowledge') enforces it.
ποΈ schema Β· π read Β· βοΈ write Β· π§ engine Β· π₯οΈ UI Β· π§ͺ test
| π¨ | File | What you do there |
|---|---|---|
| π§ | includes/knowledge/writeup_ai.php |
Prompts, the verdict protocol, ticket text bundling, richness, stemming, similarity, clustering. The interesting decisions are all here. |
| π§ | includes/knowledge/gap_analysis.php |
gapAnalyse() β score every closed ticket against the KB, cluster what nothing covers, persist. Runnable without HTTP so it can be tested. |
| π§ | includes/knowledge/kb_ai.php |
Pre-existing. kbCosineSimilarity() and kbGenerateEmbedding() are reused, not reimplemented. |
| πβοΈ | api/knowledge/analyse_gaps.php |
Thin adapter. status / embed / cluster. |
| π | api/knowledge/gap_clusters.php |
The findings + their evidence tickets. |
| βοΈ | api/knowledge/gap_dismiss.php |
Dismiss / bring back. |
| βοΈ | api/knowledge/writeup_stream.php |
SSE. Judge β refuse+questions, or article. Both entry points hit this. |
| βοΈ | api/knowledge/writeup_save.php |
Creates the unpublished draft via KnowledgeService. |
| πβοΈ | api/knowledge/assistant_settings.php |
The four tunables. Clamped server-side. |
| π₯οΈ | knowledge/assistant/index.php |
The assistant page. |
| π₯οΈ | knowledge/includes/header.php |
The Assistant nav item. |
| π₯οΈ | knowledge/settings/index.php |
Assistant section in the AI tab. |
| π₯οΈ | assets/js/inbox.js |
buildWriteUpButton() + the wu* client, appended at the end of the file. inbox.js?v= in tickets/index.php when you touch it. |
| ποΈ |
database/freeitsm.sql Β· includes/db_verify_schema.php
|
knowledge_gap_tickets, knowledge_gap_clusters, knowledge_gap_cluster_tickets. |
| π§ͺ | tests/knowledge-gaps/run.php |
The clustering harness. Read this before changing any similarity code. |
What you do NOT touch: the article renderer, the portal reader, or the AI-answer retrieval. A draft is just an article with is_published = 0, and every customer-facing reader already filtered on that.
One model call does both jobs β judge, then write. Two calls would let the second overrule the first, and a single stream is a far better experience than a spinner, a verdict, and another spinner.
The model's first line is machine-readable:
VERDICT: ARTICLE
VERDICT: NOT_ENOUGH
api/knowledge/writeup_stream.php buffers until it has that line. Nothing reaches the client until the verdict is parsed and stripped, otherwise the words VERDICT: ARTICLE appear at the top of the analyst's draft. Once resolved, deltas pass straight through.
Two guards worth knowing about:
- If the buffer passes ~200 characters with no newline, it stops waiting rather than streaming nothing for ever.
- After the stream ends, an unresolved verdict is settled from the complete text.
writeupParseResponse() is tolerant of a missing colon, a markdown fence and leading blank lines β but it never guesses. An unrecognised response is treated as not_enough, because wrongly publishing is a worse failure than wrongly asking.
writeupSystemPrompt($mode) β single, cluster, answers. All three share one body plus writeupProtocolBlock(), so the response contract cannot drift between them.
answers mode carries the line that matters: the analyst's answers are authoritative even where they contradict the thread.
writeupRichness() returns 0β100 from resolution length, notes length, conversation turns, time logged, and whether a Problem record exists.
It is used for exactly two things:
- Sorting candidates so a cluster forms around β and is drafted from β its most writable ticket.
- Deciding whether it is worth spending a model call at all.
Two implementations on purpose:
-
gapBulkRichness()(ingap_analysis.php) β four aggregate queries over all candidates. Lengths are of raw HTML bodies so they are inflated, which is fine because the score is only ever compared against other tickets. -
writeupTicketBundle()β the real plain-text bundle, read once, for the single ticket actually being written from.
Finding gaps must work on an install that will never pay an embeddings bill. So there are two engines behind one interface, and the UI says which is running.
| Meaning mode | Wording mode | |
|---|---|---|
| Needs | knowledge_openai_api_key |
nothing |
| Ticket β article | cosine over text-embedding-3-small
|
token overlap |
| Bar (article / cluster) | 0.75 / 0.82, from settings | 0.55 / 0.50, constants |
knowledgeOpenAiKey() deliberately reuses the key api/knowledge/generate_embedding.php uses. Change one without the other and every similarity score is silently meaningless β no error, just wrong answers.
writeupTokenSimilarity() returns |A β© B| / min(|A|, |B|), and 0 if fewer than two tokens are shared.
The harness is why. Jaccard divides by the union, so it punishes a subject for words the other one happens not to have:
| Pair | Jaccard | Overlap |
|---|---|---|
| "VPN keeps disconnecting every few minutes" β "VPN disconnecting again" | 0.29 | 0.67 |
Real subject lines vary wildly in length and filler, so the union is the wrong denominator for the question being asked β which is "is the shorter of these two contained in the longer?".
The two-token floor is what makes that safe. The overlap coefficient alone scores any pair sharing one word out of a two-word subject at 0.5, so "Broken window in the server room" and "Request access to the CAD licence server" would cluster on the word server. Requiring two shared tokens costs nothing real β no genuine recurring question is identified by a single word.
writeupStem() is a crude suffix stripper (-ly, -ies, -sses, -ing, -ed, -s, plus doubled-consonant collapse, never collapsing l/s/z).
Not cosmetic. Four real VPN tickets failed to cluster at all purely because "disconnects" and "disconnecting" are different strings β which is the single most common way a desk phrases the same question twice.
Numbers are stripped before tokenising, deliberately: "Laptop LT0431 won't boot" and "Laptop LT0899 won't boot" are the same question, and leaving the asset tags in hides the biggest recurring gaps behind a wall of unique-looking subjects.
writeupCluster() grows a cluster outwards from a seed, pulling in anything close to any member, not just the seed.
vpn0 β vpn3 0.67
vpn1 β vpn3 0.67
vpn0 β vpn1 0.40 <- one mentions minutes, the other mentions home
Seeded on vpn0, a seed-only pass produces {vpn0, vpn3} β two tickets, below the minimum, never reported. The assistant stays silent about the thing it most needed to say. Chaining outwards gives {vpn0, vpn3, vpn1}, which is the truth.
Single linkage can over-chain in principle. Here the pairing bar is high, wording mode needs two matching tokens before returning any score at all, and the harness carries six unrelated one-off tickets that must never end up in a cluster.
O(nΒ²) and unapologetic: n is gap candidates in a 90-day window.
π A freshly computed cluster is matched to a stored one by TICKET OVERLAP (β₯ 50%), never by id, seed or label.
A cluster grows as new tickets close, and its seed changes as richer examples arrive. Any identity built on "same seed" or "same subject" breaks on the next run and re-raises something the analyst has already dismissed β which would make the dismiss button useless within a week.
The rest of gapPersistClusters() follows from that:
- computed fields are updated in place;
statusandarticle_idare then restored, so an update can never quietly reopen a decision - an open cluster this run no longer sees is deleted β usually because an article now covers it, so leaving the card would be a lie
- dismissed and written clusters are kept regardless: those are the analyst's decisions, not our findings
| Concern | Where it is enforced |
|---|---|
| Nothing unreviewed reaches a customer |
is_published = 0 on the draft. KB_VISIBLE_SQL (kb_ai.php) and includes/knowledge/portal_reader.php both already require is_published = 1. |
| Drafts are still findable by analysts |
api/knowledge/knowledge_articles.php β the only reader that lists unpublished articles. |
| No workflow announces a draft |
KnowledgeService::saveArticle skips knowledge.published when is_published is false. |
| Model output never hits raw innerHTML |
safeHtmlFragment() on both clients β the one shared sanitiser, same rule as every message body. |
| Ticket scope |
analystCanAccessTicket() in writeup_stream.php and writeup_save.php. A ticket reached via a cluster is still checked. |
| Cluster scope |
activeTenantFilter(..., 'c') on the read and on the dismiss write β a cluster id is one guess away. |
| Spending money |
Cap::KNOWLEDGE_EMBEDDINGS on analyse_gaps.php, the same capability that guards re-embedding articles. |
| Settings sanity |
assistant_settings.php clamps every value. The number inputs' min/max are a convenience, not the guard. |
activeTenantFilter). Do not reach for knowledgeTenantFilter here β in Knowledge, NULL means shared with every company, which is exactly backwards for a ticket-derived row.
writeupSchemaReady() is a cached SHOW TABLES check, and it is load-bearing.
An install that pulls this update but has not run Database Verification must degrade to "the assistant has nothing to show you" β not an Unknown column fatal that takes out the Knowledge module. Same gate, same reason, as snoozeSchemaReady() in the ticket inbox.
Remember a PHP fatal is served as HTTP 200. Check the body, not the status code.
php tests/knowledge-gaps/run.php
16 checks, everything inside one transaction that is always rolled back, in wording mode so it spends nothing. The fixture is five recurring questions phrased the way real people phrase them, plus six genuine one-offs.
The discipline worth copying:
- every negative assertion is paired with a positive control. "The one-offs did not cluster" is equally true of a harness that clustered nothing at all, so it is only meaningful next to "the five themes did cluster".
- "the dismissed cluster is still dismissed" is paired with "the ones we did not dismiss are still open".
- the final section asserts the rollback actually rolled back. Without it the harness is a liability, not a test β it writes to a live database.
Both algorithm decisions in Β§4 came out of this harness failing. Run it before and after touching anything in writeup_ai.php.
Forge a session and curl the SSE endpoint directly β it streams plain text and is easy to read:
curl -s -b "PHPSESSID=<id>" -H 'Content-Type: application/json' \
-d '{"ticket_id":209}' http://localhost/freeitsm-app/api/knowledge/writeup_stream.php{"ticket_id":N,"answers":"..."} and getting VERDICT: ARTICLE.
- Knowledge Assistant β the user-facing page
- Knowledge Base Β· AI Providers
- Database Verification Developer Guide
- Multi-Tenancy Developer Guide
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)