1use serde::Serialize;
6use serde_json::{json, Value};
7
8use crate::{
9 assistant::{self, Answer, AssistantError, McpTools, Question, ToolUse, Trace},
10 auth::Actor,
11 error::{AppError, AppResult},
12 pagecontext::PageContext,
13 services::{
14 assistant_threads as threads, audit,
15 project_policy::{self, Interface},
16 },
17 AppState,
18};
19
20#[derive(Debug, Serialize)]
22pub struct Run {
23 pub action: assistant::Action,
24 pub answer: Answer,
25 pub tools: Vec<ToolUse>,
26 pub steps: u32,
27 pub provider: &'static str,
28 pub model: String,
29 pub allow_drafts: bool,
30 pub input_tokens: u64,
31 pub output_tokens: u64,
32 #[serde(skip_serializing_if = "Vec::is_empty")]
36 pub links: Vec<assistant::OpenLink>,
37 #[serde(skip_serializing_if = "Option::is_none")]
40 pub context: Option<ContextNote>,
41}
42
43#[derive(Debug, Clone, Serialize)]
46pub struct ContextNote {
47 pub kind: String,
48 pub label: String,
49}
50
51pub fn switches(config: Option<&crate::config::AssistantConfig>) -> Vec<Interface> {
55 let mut switches = vec![Interface::Assistant];
56 if !config.is_some_and(|c| c.is_internal()) {
57 switches.push(Interface::ExternalLlm);
58 }
59 switches
60}
61
62pub fn enabled(st: &AppState) -> bool {
64 st.assistant.is_some()
65}
66
67fn audit_details(
69 question: &Question,
70 allow_drafts: bool,
71 provider: &str,
72 model: &str,
73 trace: &Trace,
74 outcome: &str,
75) -> Value {
76 json!({
77 "provider": provider,
78 "usage_reported": trace.unreported_steps == 0,
79 "action": question.action.as_str(),
80 "question_chars": question.text.chars().count(),
81 "context": {
82 "item": question.item,
83 "revision": question.revision,
84 "change": question.change,
85 },
86 "page": {
89 "kind": question.page.kind,
90 "request": question.page.request,
91 "project": question.page.project,
92 "view": question.page.view,
93 "rule": question.page.rule,
94 "has_query": question.page.query.is_some(),
95 "filters": question.page.filters.len(),
96 "rows_shown": question.page.shown.len(),
97 },
98 "thread": question.thread,
99 "history_messages": question.history.len(),
100 "allow_drafts": allow_drafts,
101 "model": model,
102 "tools": trace.tools.iter().map(|t| t.name.as_str()).collect::<Vec<_>>(),
103 "steps": trace.steps,
104 "input_tokens": trace.input_tokens,
105 "output_tokens": trace.output_tokens,
106 "outcome": outcome,
107 })
108}
109
110async fn record_run(
111 st: &AppState,
112 me: &Actor,
113 action: assistant::Action,
114 details: Value,
115) -> AppResult<()> {
116 let mut conn = st.db.acquire().await?;
117 audit::record(
118 &mut conn,
119 Some(me),
120 "assistant.run",
121 "assistant",
122 None,
123 format!("Assistant: {}", action.label()),
124 details,
125 )
126 .await
127}
128
129struct CancelGuard {
134 st: AppState,
135 me: Actor,
136 question: Question,
137 allow_drafts: bool,
138 provider: &'static str,
139 model: String,
140 trace: Trace,
141 finished: bool,
142}
143
144impl Drop for CancelGuard {
145 fn drop(&mut self) {
146 if self.finished || self.trace.steps == 0 {
148 return;
149 }
150 let Ok(runtime) = tokio::runtime::Handle::try_current() else {
151 return;
152 };
153 let details = audit_details(
154 &self.question,
155 self.allow_drafts,
156 self.provider,
157 &self.model,
158 &self.trace,
159 "cancelled",
160 );
161 let (st, me, action) = (self.st.clone(), self.me.clone(), self.question.action);
162 runtime.spawn(async move {
163 if let Err(e) = record_run(&st, &me, action, details).await {
164 tracing::warn!(error = %e, "recording a cancelled assistant run failed");
165 }
166 });
167 }
168}
169
170pub async fn ask(st: &AppState, me: &Actor, question: Question) -> AppResult<Run> {
174 Ok(run_once(st, me, question, false).await?.0)
175}
176
177async fn run_once(
181 st: &AppState,
182 me: &Actor,
183 question: Question,
184 prior_taint: bool,
185) -> AppResult<(Run, bool)> {
186 let Some(client) = st.assistant.as_deref() else {
187 return Err(AssistantError::Disabled.into_app_error());
188 };
189 let Ok(_permit) = client.permits.try_acquire() else {
190 return Err(AssistantError::Busy.into_app_error());
191 };
192 let narrowed =
194 project_policy::narrow(&st.db, me.clone(), &switches(st.config.assistant.as_ref())).await?;
195 let me = &narrowed;
196 let allow_drafts = question.allow_drafts && me.can_edit();
197 let system = assistant::system_prompt(me, allow_drafts);
198 let instruction = question.instruction();
199 let tools = McpTools::new(st, me).with_origin(question.origin.clone());
200 if prior_taint {
201 tools
202 .tainted
203 .store(true, std::sync::atomic::Ordering::SeqCst);
204 }
205 tools.note_context(&question).await;
206 tools.note_page(&question.page).await;
207 let mut guard = CancelGuard {
208 st: st.clone(),
209 me: me.clone(),
210 question,
211 allow_drafts,
212 provider: client.provider_name,
213 model: client.model.clone(),
214 trace: Trace::default(),
215 finished: false,
216 };
217 let result = tokio::time::timeout(
218 client.run_timeout,
219 assistant::run_loop(
220 client.provider.as_ref(),
221 &client.transport,
222 &tools,
223 &client.limits,
224 &client.model,
225 &system,
226 &instruction,
227 allow_drafts,
228 &mut guard.trace,
229 ),
230 )
231 .await
232 .unwrap_or(Err(AssistantError::Timeout));
233
234 let outcome = match &result {
235 Ok(a) if a.truncated => "truncated",
236 Ok(_) => "ok",
237 Err(e) => e.code(),
238 };
239 if let Err(e) = &result {
240 tracing::warn!(user = %me.username, action = guard.question.action.as_str(), error = %e, "assistant run failed");
241 }
242 guard.finished = true;
245 let details = audit_details(
246 &guard.question,
247 allow_drafts,
248 client.provider_name,
249 &client.model,
250 &guard.trace,
251 outcome,
252 );
253 record_run(st, me, guard.question.action, details).await?;
254
255 let answer = result.map_err(AssistantError::into_app_error)?;
256 let tainted = tools.tainted.load(std::sync::atomic::Ordering::SeqCst);
257 let trace = std::mem::take(&mut guard.trace);
258 let links = assistant::open_links(&trace.open_hints, &st.config.public_url);
259 Ok((
260 Run {
261 action: guard.question.action,
262 answer,
263 tools: trace.tools,
264 steps: trace.steps,
265 provider: client.provider_name,
266 model: client.model.clone(),
267 allow_drafts,
268 input_tokens: trace.input_tokens,
269 output_tokens: trace.output_tokens,
270 links,
271 context: None,
272 },
273 tainted,
274 ))
275}
276
277pub const MAX_LIMITS_CHARS: usize = 500;
279
280pub fn is_report_file(filename: &str) -> bool {
283 let ext = filename
284 .rsplit_once('.')
285 .map(|(_, e)| e.to_ascii_lowercase())
286 .unwrap_or_default();
287 matches!(
288 ext.as_str(),
289 "pdf" | "html" | "htm" | "xhtml" | "txt" | "md" | "rst" | "log" | "out" | "csv" | "tsv"
290 )
291}
292
293pub async fn fill_result_values(
316 st: &AppState,
317 me: &Actor,
318 revision_id: uuid::Uuid,
319 wanted: &str,
320 limits: &str,
321 files: &[String],
322) -> AppResult<(Run, usize)> {
323 if !enabled(st) {
324 return Err(AssistantError::Disabled.into_app_error());
325 }
326 let rev = crate::services::revisions::get(&st.db, me, revision_id).await?;
327 let denied = project_policy::denied_groups(&st.db, &[Interface::Assistant]).await?;
328 if rev.project_id.is_some_and(|p| denied.contains(&p)) {
329 return Err(crate::services::access::not_found_revision());
330 }
331 if rev.item_type != crate::domain::ItemType::Simulation.as_str() {
332 return Err(AppError::BadRequest(
333 "Only simulation revisions have result values.".into(),
334 ));
335 }
336 if !me.can_edit() || !me.can_edit_project(rev.project_id) {
337 return Err(AppError::Forbidden(
338 "You may not edit this simulation.".into(),
339 ));
340 }
341 if limits.chars().count() > MAX_LIMITS_CHARS {
342 return Err(AppError::BadRequest(format!(
343 "The limits note is too long (at most {MAX_LIMITS_CHARS} characters)."
344 )));
345 }
346 let known = crate::services::files::list(&st.db, me, revision_id).await?;
347 for name in files {
348 if !known
349 .iter()
350 .any(|f| &f.filename == name && is_report_file(&f.filename))
351 {
352 return Err(AppError::BadRequest(format!(
353 "`{name}` is not a report file of this revision."
354 )));
355 }
356 }
357 let mut question = Question::new(
358 Some("result_values"),
359 Some(wanted),
360 Some(&rev.item_number),
361 Some(&rev.code),
362 None,
363 true,
364 )
365 .map_err(AppError::BadRequest)?;
366 question.limits = limits.trim().to_string();
367 question.files = files.to_vec();
368 let before = crate::services::result_proposals::list(&st.db, me, revision_id, Some("pending"))
369 .await?
370 .len();
371 let run = ask(st, me, question).await?;
372 let after = crate::services::result_proposals::list(&st.db, me, revision_id, Some("pending"))
373 .await?
374 .len();
375 Ok((run, after.saturating_sub(before)))
376}
377
378pub async fn resolve_context(
390 db: &sqlx::PgPool,
391 me: &Actor,
392 page: &PageContext,
393) -> AppResult<PageContext> {
394 use crate::services::access;
395 let mut out = PageContext {
396 kind: page.kind.clone(),
397 view: page.view.clone(),
398 rule: page.rule.clone(),
399 query: page.query.clone(),
400 filters: page.filters.clone(),
401 ..PageContext::default()
402 };
403 let mut numbers: Vec<String> = Vec::new();
405 for n in page
406 .item
407 .iter()
408 .chain(page.selected.iter())
409 .chain(page.shown.iter())
410 {
411 if !numbers.contains(n) {
412 numbers.push(n.clone());
413 }
414 }
415 if !numbers.is_empty() {
416 let rows: Vec<(String, bool)> = sqlx::query_as(&format!(
417 "SELECT i.number,
418 EXISTS (SELECT 1 FROM revisions r WHERE r.item_id = i.id AND r.code = $2)
419 FROM items i
420 WHERE i.number = ANY($1) AND {}",
421 access::item_visible("i", 3)
422 ))
423 .bind(&numbers)
424 .bind(page.revision.clone().unwrap_or_default())
425 .bind(&me.hidden_projects)
426 .fetch_all(db)
427 .await?;
428 let visible = |n: &String| rows.iter().any(|(number, _)| number == n);
429 if let Some(item) = page.item.as_ref().filter(|n| visible(n)) {
430 out.item = Some(item.clone());
431 let has_revision = rows.iter().any(|(number, ok)| number == item && *ok);
432 out.revision = page.revision.clone().filter(|_| has_revision);
433 }
434 out.selected = page.selected.clone().filter(visible);
435 out.shown = page.shown.iter().filter(|n| visible(n)).cloned().collect();
436 }
437 if let Some(number) = &page.change {
438 let found: Option<String> = sqlx::query_scalar(&format!(
439 "SELECT c.number FROM changes c WHERE c.number = $1 AND {}",
440 access::change_visible("c", 2, 3)
441 ))
442 .bind(number)
443 .bind(me.id)
444 .bind(&me.hidden_projects)
445 .fetch_optional(db)
446 .await?;
447 out.change = found;
448 }
449 if let Some(number) = &page.request {
450 let found: Option<String> = sqlx::query_scalar(&format!(
451 "SELECT r.number FROM change_requests r WHERE r.number = $1 AND {}",
452 access::request_visible("r", 2, 3)
453 ))
454 .bind(number)
455 .bind(me.id)
456 .bind(&me.hidden_projects)
457 .fetch_optional(db)
458 .await?;
459 out.request = found;
460 }
461 if let Some(code) = &page.project {
462 let found: Option<String> = sqlx::query_scalar(&format!(
463 "SELECT p.code FROM projects p WHERE p.code = $1 AND {}",
464 access::project_visible("p.id", 2)
465 ))
466 .bind(code)
467 .bind(&me.hidden_projects)
468 .fetch_optional(db)
469 .await?;
470 out.project = found;
471 }
472 Ok(out)
473}
474
475#[allow(clippy::too_many_arguments)]
479pub async fn chat(
480 st: &AppState,
481 me: &Actor,
482 action: Option<&str>,
483 text: Option<&str>,
484 context: &Value,
485 allow_drafts: bool,
486 thread: bool,
487 origin: crate::auth::Origin,
488) -> AppResult<Run> {
489 if !enabled(st) {
490 return Err(AssistantError::Disabled.into_app_error());
491 }
492 for field in ["item", "revision", "change"] {
494 if let Some(v) = context.get(field).and_then(Value::as_str) {
495 if !v.trim().is_empty() && !crate::pagecontext::is_reference(v.trim()) {
496 return Err(AppError::BadRequest(format!(
497 "{field}: not a valid reference."
498 )));
499 }
500 }
501 }
502 let page = PageContext::parse(context);
503 let question = Question::new(
504 action,
505 text,
506 page.item.as_deref(),
507 page.revision.as_deref(),
508 page.change.as_deref(),
509 allow_drafts,
510 )
511 .map_err(AppError::BadRequest)?;
512 let question = Question { origin, ..question };
513 ask_with_page(st, me, question, &page, thread).await
514}
515
516pub async fn ask_with_page(
542 st: &AppState,
543 me: &Actor,
544 mut question: Question,
545 page: &PageContext,
546 thread: bool,
547) -> AppResult<Run> {
548 let Some(client) = st.assistant.as_deref() else {
549 return Err(AssistantError::Disabled.into_app_error());
550 };
551 let resolved = resolve_context(&st.db, me, page).await?;
552 question.item = resolved.item.clone();
555 question.revision = resolved.revision.clone();
556 question.change = resolved.change.clone();
557 question.page = resolved.clone();
558 question.thread = thread;
559 match question.action.needs() {
560 "revision" if question.item.is_none() => {
561 return Err(AppError::BadRequest(format!(
562 "“{}” needs an item.",
563 question.action.label()
564 )))
565 }
566 "change" if question.change.is_none() => {
567 return Err(AppError::BadRequest(format!(
568 "“{}” needs a change order.",
569 question.action.label()
570 )))
571 }
572 _ => {}
573 }
574 let mut prior_taint = false;
575 if thread {
576 let stored = threads::load(&st.db, me, client.retention_days).await?;
577 question.history = threads::history_of(&stored);
578 prior_taint = threads::was_tainted(&stored);
579 }
580 let action = question.action;
581 let text = question.text.clone();
582 let (mut run, tainted) = run_once(st, me, question, prior_taint).await?;
583 let note = ContextNote {
584 kind: resolved.kind.clone(),
585 label: resolved.label(),
586 };
587 if thread {
588 let shown = match (action, text.is_empty()) {
589 (assistant::Action::Ask, _) => text,
590 (_, true) => action.label().to_string(),
591 (_, false) => format!("{} – {text}", action.label()),
592 };
593 let stored = async {
594 let mut tx = st.db.begin().await?;
595 threads::append(
596 &mut tx,
597 me,
598 &threads::Exchange {
599 action: action.as_str(),
600 question: &shown,
601 context_kind: ¬e.kind,
602 context_label: ¬e.label,
603 answer: &run.answer.text,
604 tainted,
605 },
606 )
607 .await?;
608 tx.commit().await?;
609 AppResult::Ok(())
610 }
611 .await;
612 if let Err(e) = stored {
613 tracing::warn!(error = %e, "storing an assistant exchange failed");
615 }
616 }
617 run.context = Some(note);
618 Ok(run)
619}
620
621pub fn admin_overview(
625 local_agents: crate::config::LocalAgents,
626 assistant: Option<&crate::config::AssistantConfig>,
627) -> Value {
628 let mode = match (local_agents.allowed(), assistant.is_some()) {
630 (false, true) => "company-ai",
631 (false, false) => "no-ai",
632 (true, true) => "both",
633 (true, false) => "local-only",
634 };
635 json!({
636 "mode": mode,
637 "local_agents": local_agents.as_str(),
638 "local_agents_allowed": local_agents.allowed(),
639 "assistant": assistant.map(|a| json!({
640 "provider": a.provider.as_str(),
641 "model": a.model,
642 "host": a.host(),
643 "key_set": a.api_key.is_some(),
644 "max_steps": a.max_steps,
645 "max_tokens": a.max_tokens,
646 "max_concurrent": a.max_concurrent,
647 "run_timeout_secs": a.run_timeout.as_secs(),
648 "retention_days": a.retention_days,
649 })),
650 })
651}
652
653#[cfg(test)]
654mod tests {
655 use super::*;
656 use crate::config::{AssistantConfig, AssistantProvider, LocalAgents};
657 use std::time::Duration;
658
659 fn sample(key: Option<&str>) -> AssistantConfig {
660 AssistantConfig {
661 provider: AssistantProvider::OpenAiCompatible,
662 base_url: "https://ai.example.internal/v1/secret-path".into(),
663 api_key: key.map(str::to_string),
664 model: "llama-4-70b".into(),
665 max_steps: 8,
666 max_tokens: 2048,
667 request_timeout: Duration::from_secs(60),
668 run_timeout: Duration::from_secs(180),
669 max_concurrent: 4,
670 retention_days: 30,
671 internal_urls: Vec::new(),
672 }
673 }
674
675 #[test]
676 fn report_files_are_pdf_html_and_text() {
677 for ok in [
678 "report.pdf",
679 "REPORT.PDF",
680 "r.html",
681 "r.htm",
682 "notes.txt",
683 "solver.out",
684 ] {
685 assert!(is_report_file(ok), "{ok}");
686 }
687 for no in [
688 "model.step",
689 "result.vtk",
690 "noextension",
691 "archive.zip",
692 "pdf",
693 ] {
694 assert!(!is_report_file(no), "{no}");
695 }
696 }
697
698 #[test]
699 fn admin_overview_names_provider_and_model_but_never_the_key() {
700 let cfg = sample(Some("sk-very-secret-key"));
701 let v = admin_overview(LocalAgents::Off, Some(&cfg));
702 assert_eq!(v["mode"], "company-ai");
703 assert_eq!(v["local_agents"], "off");
704 assert_eq!(v["assistant"]["provider"], "openai-compatible");
705 assert_eq!(v["assistant"]["model"], "llama-4-70b");
706 assert_eq!(v["assistant"]["host"], "ai.example.internal");
707 assert_eq!(v["assistant"]["key_set"], true);
708 let text = v.to_string();
709 assert!(!text.contains("sk-very-secret-key"), "{text}");
710 assert!(!text.contains("secret-path"), "host only: {text}");
711
712 let v = admin_overview(LocalAgents::Off, None);
713 assert_eq!(v["mode"], "no-ai");
714 assert!(v["assistant"].is_null());
715 assert_eq!(
716 admin_overview(LocalAgents::Allowed, Some(&cfg))["mode"],
717 "both"
718 );
719 assert_eq!(
720 admin_overview(LocalAgents::Allowed, None)["mode"],
721 "local-only"
722 );
723 let v = admin_overview(LocalAgents::Allowed, Some(&sample(None)));
725 assert_eq!(v["assistant"]["key_set"], false);
726 }
727}