mike-backend / backend /src /lib /documentMode.ts
bardd's picture
Deploy Mike backend-only Space
06671aa verified
Raw History Blame Contribute Delete
22.3 kB
import type { ChatMessage, DocIndex } from "./chatTools";
import type { RunTree } from "langsmith";
import type { UserApiKeys } from "./llm/types";
import {
buildSaudiLawRoutingResult,
classifySaudiLawNeed,
type SaudiLawRoutingResult,
} from "./saudiLawRouting";
import { classifyWebSearchNeed, detectWebSearchIntentHeuristic } from "./webSearchRouting";
/**
* Explicit assistant-mode contract. Every chat request resolves to exactly
* one of these. `auto` means "the server may infer a mode from heuristics" —
* every other value is an explicit user choice that is already authorized by
* the user's selection and must never be silently upgraded to a mutating mode.
*/
export type RequestedAssistantMode =
| "auto"
| "answer"
| "comments"
| "document_edit"
| "workflow_review"
| "document_generation"
| "web_search";
const REQUESTED_ASSISTANT_MODES: readonly RequestedAssistantMode[] = [
"auto",
"answer",
"comments",
"document_edit",
"workflow_review",
"document_generation",
"web_search",
];
/**
* Validate an untrusted `requested_mode` request field. Throws a tagged error
* whose message contains `invalid_requested_mode` so routes can translate it to
* an HTTP 400 before any model/provider/tool/storage/database side effect.
*/
export function parseRequestedAssistantMode(
value: unknown,
): RequestedAssistantMode {
if (value === undefined || value === null || value === "") return "auto";
if (
typeof value === "string" &&
(REQUESTED_ASSISTANT_MODES as readonly string[]).includes(value)
) {
return value as RequestedAssistantMode;
}
throw new Error(`invalid_requested_mode: ${String(value)}`);
}
export type AssistantTurnMode =
| "answer"
| "comments"
| "document_direct_edit"
| "document_edit_with_web_search"
| "web_search"
| "workflow_review"
| "document_generation"
| "project_chat";
export type DocumentModeDecision = {
trackChanges: boolean;
reason:
| "explicit_request_true"
| "explicit_comments_only"
| "auto_docx_edit_intent"
| "no_editable_docx"
| "default_comments_only"
| "explicit_non_mutating_mode";
editIntent: boolean;
commentsOnlyIntent: boolean;
editableDocxCount: number;
pdfCount: number;
};
export type TurnModeSource = "explicit" | "inferred";
export type TurnModeReason =
| "explicit_mode"
| "explicit_workflow_execution"
| "user_requested_workflow"
| "docx_mutation_with_web_search"
| "direct_docx_mutation"
| "web_search_intent"
| "web_search_routing"
| "document_generation_intent"
| "normal_answer"
| "saudi_law_requires_web_search"
| "web_search_blocked_docs_attached";
export type TurnModeDecision = {
turnMode: AssistantTurnMode;
requestedMode: RequestedAssistantMode;
source: TurnModeSource;
documentMode: DocumentModeDecision;
enableGoogleSearch: boolean;
requiresConfirmation: boolean;
reason: TurnModeReason;
saudiLawRouting?: SaudiLawRoutingResult;
routingContext?: {
followUpDetected: boolean;
inheritedWebSearch: boolean;
routingText: string;
inheritanceSource: "deterministic" | "none";
};
};
const COMMENTS_ONLY_RE =
/\b(comments? only|feedback only|review only|marginalia|do not edit|don't edit|no edits|no redlines|without changing|do not change|leave the text unchanged)\b/i;
const EDIT_INTENT_RE =
/\b(rewrite|redline|red-line|edit|revise|amend|modify|change|replace|insert|add|delete|remove|fix|correct|tracked changes?|make .* favourable|make .* favorable|more favourable|more favorable)\b/i;
const COMMENT_EDIT_INTENT_RE =
/\b(add|insert|place|leave|make|create)\b.{0,40}\b(comment|note|annotation|margin note)\b/i;
const UPDATE_DOCUMENT_INTENT_RE =
/\bupdate\b.{0,80}\b(document|docx|file|agreement|contract|clause|section|paragraph|text|wording|draft)\b|\b(document|docx|file|agreement|contract|clause|section|paragraph|text|wording|draft)\b.{0,80}\bupdate\b/i;
const WORKFLOW_REVIEW_RE =
/\b(run|execute|start|apply|use)\b.{0,80}\b(workflow|playbook|review workflow|buyer playbook|seller playbook|zamil playbook)\b/i;
const DOCUMENT_GENERATION_RE =
/\b(draft|generate|create|produce|prepare|write)\b.{0,80}\b(document|docx|agreement|contract|letter|memo|notice|template|deed|resolution)\b/i;
const FOLLOW_UP_FRAGMENT_RE =
/^\s*(for|in|at|near|around|and|also|same(?:\s+for)?|what about|how about|now|today|tomorrow|yesterday|next week|this week|source|sources|cite|citation|citations|verify|fact[- ]?check|in arabic|arabic|translate)\b/i;
const REFERENTIAL_FOLLOW_UP_RE =
/\b(that|this|it|those|these|same|above|previous|earlier|there|here)\b/i;
const TOPIC_RESET_RE =
/\b(new topic|different question|unrelated|forget that|instead)\b/i;
const PRIOR_TURN_CONTEXT_CHAR_LIMIT = 600;
export type PriorTurnRoutingState = {
turnMode?: AssistantTurnMode | string | null;
enableGoogleSearch?: boolean | null;
reason?: string | null;
};
export function extractPriorTurnRoutingState(
content: unknown,
): PriorTurnRoutingState | null {
if (!Array.isArray(content)) return null;
const modeEvent = content.find((event): event is Record<string, unknown> => {
return (
typeof event === "object" &&
event !== null &&
(event as Record<string, unknown>).type === "mode_decision"
);
});
const decision = modeEvent?.decision;
if (typeof decision !== "object" || decision === null) return null;
const record = decision as Record<string, unknown>;
return {
turnMode:
typeof record.turnMode === "string" ? record.turnMode : undefined,
enableGoogleSearch:
typeof record.enableGoogleSearch === "boolean"
? record.enableGoogleSearch
: undefined,
reason: typeof record.reason === "string" ? record.reason : undefined,
};
}
function detectWebSearchIntent(
lastUserText: string,
hasAttachedDocs = false,
): boolean {
return detectWebSearchIntentHeuristic(lastUserText, { hasAttachedDocs });
}
type DocIndexEntry = {
filename?: string;
file_type?: string | null;
};
function isDocx(info: DocIndexEntry): boolean {
const type = info.file_type?.toLowerCase();
const name = info.filename?.toLowerCase() ?? "";
return type === "docx" || name.endsWith(".docx");
}
function isPdf(info: DocIndexEntry): boolean {
const type = info.file_type?.toLowerCase();
const name = info.filename?.toLowerCase() ?? "";
return type === "pdf" || name.endsWith(".pdf");
}
function latestUserText(messages: ChatMessage[]): string {
const lastUser = [...messages].reverse().find((m) => m.role === "user");
if (!lastUser?.content) return "";
return String(lastUser.content);
}
function previousUserText(messages: ChatMessage[]): string {
let seenLatest = false;
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message.role !== "user") continue;
if (!seenLatest) {
seenLatest = true;
continue;
}
return String(message.content ?? "");
}
return "";
}
function priorStateNeedsWebSearch(
prior: PriorTurnRoutingState | null | undefined,
): boolean {
if (!prior) return false;
if (prior.enableGoogleSearch === true) return true;
return (
prior.turnMode === "web_search" ||
prior.turnMode === "document_edit_with_web_search"
);
}
function isLikelyFollowUpFragment(text: string): boolean {
const trimmed = text.trim();
if (!trimmed) return false;
if (TOPIC_RESET_RE.test(trimmed)) return false;
if (WORKFLOW_REVIEW_RE.test(trimmed) || DOCUMENT_GENERATION_RE.test(trimmed)) {
return false;
}
if (EDIT_INTENT_RE.test(trimmed) || COMMENT_EDIT_INTENT_RE.test(trimmed)) {
return false;
}
if (FOLLOW_UP_FRAGMENT_RE.test(trimmed)) return true;
if (REFERENTIAL_FOLLOW_UP_RE.test(trimmed) && trimmed.length <= 140) {
return true;
}
if (
trimmed.length <= 32 &&
!/\b(what|who|why|when|where|how|draft|generate|rewrite|review|explain|tell|define)\b/i.test(
trimmed,
)
) {
return true;
}
return false;
}
function buildFollowUpRoutingContext(params: {
messages: ChatMessage[];
priorTurn?: PriorTurnRoutingState | null;
}): {
followUpDetected: boolean;
inheritedWebSearch: boolean;
routingText: string;
inheritanceSource: "deterministic" | "none";
} {
const latest = latestUserText(params.messages).trim();
const previous = previousUserText(params.messages).trim();
if (!latest || !previous || !isLikelyFollowUpFragment(latest)) {
return {
followUpDetected: false,
inheritedWebSearch: false,
routingText: latest,
inheritanceSource: "none",
};
}
const previousNeededWeb =
priorStateNeedsWebSearch(params.priorTurn) ||
detectWebSearchIntent(previous);
const routingText = `${previous.slice(0, PRIOR_TURN_CONTEXT_CHAR_LIMIT)}\nFollow-up: ${latest}`;
return {
followUpDetected: true,
inheritedWebSearch: previousNeededWeb,
routingText,
inheritanceSource: previousNeededWeb ? "deterministic" : "none",
};
}
function latestUserHasWorkflow(messages: ChatMessage[]): boolean {
const lastUser = [...messages].reverse().find((m) => m.role === "user");
return !!lastUser?.workflow?.id;
}
export function resolveDocumentMode(params: {
requestedTrackChanges?: boolean;
messages: ChatMessage[];
docIndex: DocIndex;
}): DocumentModeDecision {
const lastUserText = latestUserText(params.messages);
const docs = Object.values(params.docIndex);
const editableDocxCount = docs.filter(isDocx).length;
const pdfCount = docs.filter(isPdf).length;
const hasEditableDocx = editableDocxCount > 0;
const commentsOnlyIntent = COMMENTS_ONLY_RE.test(lastUserText);
const editIntent =
EDIT_INTENT_RE.test(lastUserText) ||
COMMENT_EDIT_INTENT_RE.test(lastUserText) ||
UPDATE_DOCUMENT_INTENT_RE.test(lastUserText);
if (
params.requestedTrackChanges === true &&
hasEditableDocx &&
!commentsOnlyIntent
) {
return {
trackChanges: true,
reason: "explicit_request_true",
editIntent,
commentsOnlyIntent,
editableDocxCount,
pdfCount,
};
}
if (commentsOnlyIntent) {
return {
trackChanges: false,
reason: "explicit_comments_only",
editIntent,
commentsOnlyIntent,
editableDocxCount,
pdfCount,
};
}
if (!hasEditableDocx) {
return {
trackChanges: false,
reason: "no_editable_docx",
editIntent,
commentsOnlyIntent,
editableDocxCount,
pdfCount,
};
}
if (editIntent) {
return {
trackChanges: true,
reason: "auto_docx_edit_intent",
editIntent,
commentsOnlyIntent,
editableDocxCount,
pdfCount,
};
}
return {
trackChanges: false,
reason: "default_comments_only",
editIntent,
commentsOnlyIntent,
editableDocxCount,
pdfCount,
};
}
function explicitTurnModeFor(
requestedMode: Exclude<RequestedAssistantMode, "auto">,
): AssistantTurnMode {
switch (requestedMode) {
case "answer":
return "answer";
case "comments":
return "comments";
case "document_edit":
return "document_direct_edit";
case "workflow_review":
return "workflow_review";
case "document_generation":
return "document_generation";
case "web_search":
return "web_search";
}
}
function resolveExplicitTurnMode(
requestedMode: Exclude<RequestedAssistantMode, "auto">,
params: {
messages: ChatMessage[];
docIndex: DocIndex;
projectId?: string | null;
},
): TurnModeDecision {
const allowTrackChanges = requestedMode === "document_edit";
let documentMode = resolveDocumentMode({
requestedTrackChanges: allowTrackChanges,
messages: params.messages,
docIndex: params.docIndex,
});
// Explicit non-mutating modes must never mutate document bytes. Force the
// document mode flat regardless of what the legacy heuristics inferred so a
// user who explicitly selected (e.g.) "answer" can never be silently
// upgraded to tracked-change editing.
if (!allowTrackChanges) {
documentMode = {
...documentMode,
trackChanges: false,
reason: "explicit_non_mutating_mode",
};
}
const lastUserText = latestUserText(params.messages);
const hasAttachedDocs = Object.keys(params.docIndex).length > 0;
const wantsWebSearch = detectWebSearchIntent(lastUserText, hasAttachedDocs);
if (requestedMode === "web_search" && hasAttachedDocs) {
return {
turnMode: params.projectId ? "project_chat" : "answer",
requestedMode,
source: "explicit",
documentMode,
enableGoogleSearch: false,
requiresConfirmation: false,
reason: "web_search_blocked_docs_attached",
};
}
return {
turnMode: explicitTurnModeFor(requestedMode),
requestedMode,
source: "explicit",
documentMode,
enableGoogleSearch:
!hasAttachedDocs &&
(requestedMode === "web_search" ||
(requestedMode === "answer" && wantsWebSearch)),
requiresConfirmation: false,
reason: "explicit_mode",
};
}
function resolveAutoTurnMode(params: {
requestedTrackChanges?: boolean;
messages: ChatMessage[];
docIndex: DocIndex;
isExecutingWorkflow?: boolean;
projectId?: string | null;
}): TurnModeDecision {
const documentMode = resolveDocumentMode({
requestedTrackChanges: params.requestedTrackChanges,
messages: params.messages,
docIndex: params.docIndex,
});
const lastUserText = latestUserText(params.messages);
const hasWorkflowAttachment = latestUserHasWorkflow(params.messages);
const hasAttachedDocs = Object.keys(params.docIndex).length > 0;
const wantsWebSearch = detectWebSearchIntent(lastUserText, hasAttachedDocs);
const finalize = (
turnMode: AssistantTurnMode,
enableGoogleSearch: boolean,
reason: TurnModeReason,
): TurnModeDecision => ({
turnMode,
requestedMode: "auto",
source: "inferred",
documentMode,
enableGoogleSearch,
requiresConfirmation:
turnMode === "document_direct_edit" ||
turnMode === "document_edit_with_web_search",
reason,
});
if (params.isExecutingWorkflow || hasWorkflowAttachment) {
return finalize("workflow_review", false, "explicit_workflow_execution");
}
if (WORKFLOW_REVIEW_RE.test(lastUserText)) {
return finalize("workflow_review", false, "user_requested_workflow");
}
if (
!hasAttachedDocs &&
documentMode.trackChanges &&
documentMode.editableDocxCount > 0 &&
wantsWebSearch
) {
return finalize(
"document_edit_with_web_search",
true,
"docx_mutation_with_web_search",
);
}
if (documentMode.trackChanges && documentMode.editableDocxCount > 0) {
return finalize("document_direct_edit", false, "direct_docx_mutation");
}
if (DOCUMENT_GENERATION_RE.test(lastUserText)) {
return finalize(
"document_generation",
!hasAttachedDocs && wantsWebSearch,
"document_generation_intent",
);
}
if (!hasAttachedDocs && wantsWebSearch) {
return finalize("web_search", true, "web_search_intent");
}
return finalize(
params.projectId ? "project_chat" : "answer",
false,
"normal_answer",
);
}
export function resolveTurnMode(params: {
requestedMode?: RequestedAssistantMode;
requestedTrackChanges?: boolean;
messages: ChatMessage[];
docIndex: DocIndex;
isExecutingWorkflow?: boolean;
projectId?: string | null;
}): TurnModeDecision {
const requestedMode = params.requestedMode ?? "auto";
if (requestedMode !== "auto") {
return resolveExplicitTurnMode(requestedMode, {
messages: params.messages,
docIndex: params.docIndex,
projectId: params.projectId,
});
}
return resolveAutoTurnMode(params);
}
export function applySaudiLawRouting(
decision: TurnModeDecision,
saudi: SaudiLawRoutingResult,
requestedMode: RequestedAssistantMode,
): TurnModeDecision {
if (!saudi.isSaudiLawQuestion) return decision;
if (
decision.turnMode === "workflow_review" ||
decision.requiresConfirmation
) {
return decision;
}
const base = {
...decision,
enableGoogleSearch: true,
saudiLawRouting: saudi,
};
if (decision.turnMode === "document_direct_edit") {
return {
...base,
turnMode: "document_edit_with_web_search",
reason: "saudi_law_requires_web_search",
};
}
if (requestedMode === "answer") {
return {
...base,
reason: "saudi_law_requires_web_search",
};
}
if (decision.enableGoogleSearch) {
return {
...base,
reason: "saudi_law_requires_web_search",
};
}
if (requestedMode === "auto") {
return {
...base,
turnMode: "web_search",
reason: "saudi_law_requires_web_search",
};
}
return {
...base,
reason: "saudi_law_requires_web_search",
};
}
function applyWebSearchRouting(
decision: TurnModeDecision,
requestedMode: RequestedAssistantMode,
routing: Awaited<ReturnType<typeof classifyWebSearchNeed>>,
): TurnModeDecision {
if (!routing.needsWebSearch || decision.enableGoogleSearch) return decision;
if (requestedMode === "answer") {
return {
...decision,
enableGoogleSearch: true,
reason: routing.source === "llm" ? "web_search_routing" : decision.reason,
};
}
return {
...decision,
turnMode: "web_search",
enableGoogleSearch: true,
reason: routing.source === "llm" ? "web_search_routing" : "web_search_intent",
};
}
export async function resolveTurnModeAsync(params: {
requestedMode?: RequestedAssistantMode;
requestedTrackChanges?: boolean;
messages: ChatMessage[];
docIndex: DocIndex;
isExecutingWorkflow?: boolean;
projectId?: string | null;
apiKeys?: UserApiKeys;
routingTrace?: RunTree | null;
saudiRoutingTrace?: RunTree | null;
priorTurn?: PriorTurnRoutingState | null;
/** Eval/test hook — forwarded to classifySaudiLawNeed. */
saudiClassifierOverride?: (args: {
userText: string;
apiKeys?: UserApiKeys;
trace?: RunTree | null;
}) => Promise<string>;
}): Promise<TurnModeDecision> {
const requestedMode = params.requestedMode ?? "auto";
let decision = resolveTurnMode(params);
const lastUserText = latestUserText(params.messages);
const followUpRouting = buildFollowUpRoutingContext({
messages: params.messages,
priorTurn: params.priorTurn,
});
const routingText = followUpRouting.routingText || lastUserText;
const hasAttachedDocs = Object.keys(params.docIndex).length > 0;
if (
followUpRouting.inheritedWebSearch &&
!hasAttachedDocs &&
!decision.enableGoogleSearch &&
!decision.requiresConfirmation &&
decision.turnMode !== "workflow_review" &&
decision.turnMode !== "document_generation"
) {
decision = {
...decision,
turnMode: requestedMode === "auto" ? "web_search" : decision.turnMode,
enableGoogleSearch: true,
reason: "web_search_routing",
};
}
decision = {
...decision,
routingContext: followUpRouting,
};
const skipGenericRouting =
decision.enableGoogleSearch ||
decision.requiresConfirmation ||
decision.turnMode === "workflow_review" ||
requestedMode === "web_search";
const saudiPromise =
process.env.SAUDI_LAW_ROUTING !== "0"
? classifySaudiLawNeed({
userText: routingText,
apiKeys: params.apiKeys,
trace: params.saudiRoutingTrace ?? params.routingTrace,
hasAttachedDocs,
classifierOverride: params.saudiClassifierOverride,
})
: Promise.resolve(
buildSaudiLawRoutingResult({
isSaudiLawQuestion: false,
source: "skip",
domain: "unknown",
confidence: "high",
}),
);
const genericPromise = skipGenericRouting
? Promise.resolve(null)
: classifyWebSearchNeed({
userText: routingText,
apiKeys: params.apiKeys,
trace: params.routingTrace,
hasAttachedDocs,
});
const [saudi, routing] = await Promise.all([
saudiPromise,
genericPromise,
]);
if (process.env.SAUDI_LAW_ROUTING_LOG === "1") {
console.log("[saudiLawRouting]", saudi);
}
if (saudi.isSaudiLawQuestion) {
return applySaudiLawRouting(decision, saudi, requestedMode);
}
if (skipGenericRouting || routing === null) {
return decision;
}
return applyWebSearchRouting(decision, requestedMode, routing);
}