@krotovm/gitlab-ai-review 1.0.31 → 1.0.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/dist/cli/args.js +9 -1
- package/dist/cli/ci-review.js +82 -18
- package/dist/cli/debug-artifacts-html.js +58 -1
- package/dist/cli.js +73 -50
- package/dist/prompt/index.js +68 -42
- package/dist/prompt/messages.js +15 -10
- package/dist/prompt/profile.js +29 -0
- package/dist/prompt/templates/postprocess-system.js +84 -4
- package/dist/prompt/templates/review-system.js +79 -6
- package/dist/prompt/templates/triage-system.js +32 -3
- package/dist/prompt/templates/user-prompts.js +4 -4
- package/dist/prompt/utils.js +42 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -55,6 +55,7 @@ Set these in your project/group CI settings:
|
|
|
55
55
|
- `OPENAI_API_KEY` (required)
|
|
56
56
|
- `OPENAI_BASE_URL` (optional, for OpenAI-compatible providers/proxies)
|
|
57
57
|
- `AI_MODEL` (optional, default: `gpt-4o-mini`; example: `gpt-4o`)
|
|
58
|
+
- `AI_PROMPT_PROFILE` (optional, default: `default`; one of `default` \| `weak`). Use `weak` for small or heavily-quantized models (≤ ~7B, local Ollama, etc.). The `weak` profile uses shorter system prompts, positive rules instead of negations, and inline few-shot examples for triage / per-file review / consolidation / verification, which dramatically improves output-format adherence on weak models.
|
|
58
59
|
- `PROJECT_ACCESS_TOKEN` (optional for public projects, but required for most private projects; token with `api` scope)
|
|
59
60
|
- `GITLAB_TOKEN` (optional alias for `PROJECT_ACCESS_TOKEN`)
|
|
60
61
|
- `AI_REVIEW_ARTIFACT_HTML_FILE` (optional, default: `ai-review-report.html`; used with `--include-artifacts`)
|
|
@@ -74,6 +75,7 @@ GitLab provides these automatically in Merge Request pipelines:
|
|
|
74
75
|
- `--max-diffs=50` - Max number of diffs included in the prompt.
|
|
75
76
|
- `--max-diff-chars=16000` - Max chars per diff chunk (single-pass fallback only).
|
|
76
77
|
- `--max-total-prompt-chars=220000` - Final hard cap for prompt size (single-pass fallback only).
|
|
78
|
+
- `--triage-diff-chars=2000` - Max chars per file diff sent to the triage pass (Pass 1). Increase for large diffs where the first hunks are mostly git headers.
|
|
77
79
|
- `--max-findings=5` - Max findings in the final review (CI multi-pass only).
|
|
78
80
|
- `--max-review-concurrency=5` - Parallel per-file review API calls (CI multi-pass only).
|
|
79
81
|
- `--debug` - Print full error details (stack and API error fields).
|
|
@@ -84,8 +86,8 @@ GitLab provides these automatically in Merge Request pipelines:
|
|
|
84
86
|
|
|
85
87
|
The reviewer uses a three-pass pipeline optimized for large merge requests:
|
|
86
88
|
|
|
87
|
-
1. **Triage** - A fast LLM pass classifies each changed file as `NEEDS_REVIEW` or `SKIP` and generates a short MR summary.
|
|
88
|
-
2. **Per-file review** - Only `NEEDS_REVIEW` files are reviewed
|
|
89
|
+
1. **Triage** - A fast LLM pass classifies each changed file as `NEEDS_REVIEW` or `SKIP`, with a per-file `reason`, and generates a short MR summary. Each file diff is truncated to `--triage-diff-chars` (default 2000).
|
|
90
|
+
2. **Per-file review** - Only `NEEDS_REVIEW` files are reviewed (if triage marks every file `SKIP`, all files are reviewed anyway). Each reviewed file gets a dedicated LLM call running in parallel (with tools to fetch full files or grep the repository).
|
|
89
91
|
3. **Consolidate** - Per-file findings are merged, deduplicated, ranked by severity, and trimmed to top N (default 5).
|
|
90
92
|
|
|
91
93
|
If the triage pass fails (API error, unparseable response), the pipeline falls back to the original single-pass approach automatically.
|
package/dist/cli/args.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** @format */
|
|
2
|
-
import { DEFAULT_PROMPT_LIMITS, } from "../prompt/index.js";
|
|
2
|
+
import { DEFAULT_PROMPT_LIMITS, DEFAULT_TRIAGE_DIFF_CHARS, parsePromptProfile as parsePromptProfileValue, } from "../prompt/index.js";
|
|
3
3
|
export function requireEnvs(names) {
|
|
4
4
|
const missing = names.filter((name) => {
|
|
5
5
|
const value = process.env[name];
|
|
@@ -87,7 +87,15 @@ export function parsePromptLimits(argv) {
|
|
|
87
87
|
maxTotalPromptChars: parseNumberFlag(argv, "max-total-prompt-chars", DEFAULT_PROMPT_LIMITS.maxTotalPromptChars, 1),
|
|
88
88
|
};
|
|
89
89
|
}
|
|
90
|
+
export function parseTriageDiffChars(argv) {
|
|
91
|
+
return parseNumberFlag(argv, "triage-diff-chars", DEFAULT_TRIAGE_DIFF_CHARS, 1);
|
|
92
|
+
}
|
|
90
93
|
export function hasIgnoredExtension(filePath, ignoredExtensions) {
|
|
91
94
|
const lowerPath = filePath.toLowerCase();
|
|
92
95
|
return ignoredExtensions.some((ext) => lowerPath.endsWith(ext));
|
|
93
96
|
}
|
|
97
|
+
/** Reads AI_PROMPT_PROFILE (values: "default" | "weak"). Anything else falls
|
|
98
|
+
* back to the default profile. */
|
|
99
|
+
export function readPromptProfileFromEnv() {
|
|
100
|
+
return parsePromptProfileValue(process.env["AI_PROMPT_PROFILE"]);
|
|
101
|
+
}
|
package/dist/cli/ci-review.js
CHANGED
|
@@ -1,8 +1,53 @@
|
|
|
1
1
|
/** @format */
|
|
2
2
|
import OpenAI from "openai";
|
|
3
|
-
import { buildAnswer, buildConsolidatePrompt, buildFileReviewPrompt, buildPrompt, buildTriagePrompt, buildVerificationPrompt, extractCompletionText, parseTriageResponseDetailed, parseTriageResponse, } from "../prompt/index.js";
|
|
3
|
+
import { buildAnswer, buildConsolidatePrompt, buildFileReviewPrompt, buildPrompt, buildTriagePrompt, buildVerificationPrompt, DEFAULT_PROMPT_PROFILE, extractCompletionText, isEmptyReviewBody, NO_FINDINGS_SENTENCE, parseTriageResponseDetailed, parseTriageResponse, } from "../prompt/index.js";
|
|
4
4
|
import { fetchFileAtRef, searchRepository, } from "../gitlab/services.js";
|
|
5
5
|
import { logToolUsageMinimal, MAX_FILE_TOOL_ROUNDS, MAX_TOOL_ROUNDS, MAX_VERIFICATION_TOOL_ROUNDS, TOOL_NAME_GET_FILE, TOOL_NAME_GREP, } from "./tooling.js";
|
|
6
|
+
function buildTriageDecisions(params) {
|
|
7
|
+
const { changes, triageResult } = params;
|
|
8
|
+
const triageMap = new Map(triageResult.files.map((f) => [f.path, f]));
|
|
9
|
+
let reviewFiles = changes.filter((c) => triageMap.get(c.new_path)?.verdict !== "SKIP");
|
|
10
|
+
const skipAllOverride = reviewFiles.length === 0;
|
|
11
|
+
if (skipAllOverride)
|
|
12
|
+
reviewFiles = changes;
|
|
13
|
+
const decisions = changes.map((change) => {
|
|
14
|
+
const path = change.new_path;
|
|
15
|
+
const triageFile = triageMap.get(path);
|
|
16
|
+
const verdict = triageFile?.verdict ?? "NEEDS_REVIEW";
|
|
17
|
+
const reason = triageFile?.reason ??
|
|
18
|
+
(triageFile == null ? "not listed in triage response" : undefined);
|
|
19
|
+
let review_action;
|
|
20
|
+
if (verdict === "NEEDS_REVIEW" || triageFile == null) {
|
|
21
|
+
review_action = "review";
|
|
22
|
+
}
|
|
23
|
+
else if (skipAllOverride) {
|
|
24
|
+
review_action = "reviewed_via_override";
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
review_action = "skipped";
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
path,
|
|
31
|
+
verdict,
|
|
32
|
+
...(reason != null ? { reason } : {}),
|
|
33
|
+
review_action,
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
return { decisions, skipAllOverride, reviewFiles };
|
|
37
|
+
}
|
|
38
|
+
function formatTriageDecisionLine(decision) {
|
|
39
|
+
const reasonSuffix = decision.reason != null && decision.reason !== ""
|
|
40
|
+
? ` — ${decision.reason}`
|
|
41
|
+
: "";
|
|
42
|
+
switch (decision.review_action) {
|
|
43
|
+
case "skipped":
|
|
44
|
+
return ` Skipped: ${decision.path} (${decision.verdict})${reasonSuffix}`;
|
|
45
|
+
case "reviewed_via_override":
|
|
46
|
+
return ` Reviewed via override: ${decision.path} (triage=${decision.verdict})${reasonSuffix}`;
|
|
47
|
+
default:
|
|
48
|
+
return ` Review: ${decision.path} (${decision.verdict})${reasonSuffix}`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
6
51
|
async function appendDebugDump(_debugDumpFile, debugRecordWriter, record) {
|
|
7
52
|
const withTs = { ts: new Date().toISOString(), ...record };
|
|
8
53
|
if (debugRecordWriter != null) {
|
|
@@ -162,13 +207,14 @@ async function mapWithConcurrency(items, concurrency, fn) {
|
|
|
162
207
|
return results;
|
|
163
208
|
}
|
|
164
209
|
export async function reviewMergeRequestWithTools(params) {
|
|
165
|
-
const { openaiInstance, aiModel, promptLimits, changes, refs, gitLabProjectApiUrl, projectId, headers, forceTools, loggers, debugDumpFile, debugRecordWriter, } = params;
|
|
210
|
+
const { openaiInstance, aiModel, promptLimits, changes, refs, gitLabProjectApiUrl, projectId, headers, forceTools, promptProfile = DEFAULT_PROMPT_PROFILE, loggers, debugDumpFile, debugRecordWriter, } = params;
|
|
166
211
|
const { logDebug, logStep } = loggers;
|
|
167
212
|
logStep(`Single-pass review started for ${changes.length} file(s) (fallback mode).`);
|
|
168
213
|
const messages = buildPrompt({
|
|
169
214
|
changes: changes.map((change) => ({ diff: change.diff })),
|
|
170
215
|
limits: promptLimits,
|
|
171
216
|
allowTools: true,
|
|
217
|
+
profile: promptProfile,
|
|
172
218
|
});
|
|
173
219
|
messages.push({
|
|
174
220
|
role: "user",
|
|
@@ -309,7 +355,7 @@ export async function reviewMergeRequestWithTools(params) {
|
|
|
309
355
|
return buildAnswer(finalCompletion);
|
|
310
356
|
}
|
|
311
357
|
async function runFileReviewWithTools(params) {
|
|
312
|
-
const { openaiInstance, aiModel, filePath, fileDiff, summary, otherChangedFiles, refs, gitLabProjectApiUrl, projectId, headers, forceTools, loggers, debugDumpFile, debugRecordWriter, } = params;
|
|
358
|
+
const { openaiInstance, aiModel, filePath, fileDiff, summary, otherChangedFiles, refs, gitLabProjectApiUrl, projectId, headers, forceTools, promptProfile, loggers, debugDumpFile, debugRecordWriter, } = params;
|
|
313
359
|
const { logDebug, logStep } = loggers;
|
|
314
360
|
const messages = buildFileReviewPrompt({
|
|
315
361
|
filePath,
|
|
@@ -317,6 +363,7 @@ async function runFileReviewWithTools(params) {
|
|
|
317
363
|
summary,
|
|
318
364
|
otherChangedFiles,
|
|
319
365
|
allowTools: true,
|
|
366
|
+
profile: promptProfile,
|
|
320
367
|
});
|
|
321
368
|
const tools = [
|
|
322
369
|
{
|
|
@@ -378,11 +425,11 @@ async function runFileReviewWithTools(params) {
|
|
|
378
425
|
});
|
|
379
426
|
const msg = completion.choices[0]?.message;
|
|
380
427
|
if (msg == null)
|
|
381
|
-
return extractCompletionText(completion) ??
|
|
428
|
+
return extractCompletionText(completion) ?? NO_FINDINGS_SENTENCE;
|
|
382
429
|
const toolCalls = msg.tool_calls ?? [];
|
|
383
430
|
logDebug(`file-review path=${filePath} round=${round + 1} tool_calls=${toolCalls.length} finish_reason=${completion.choices[0]?.finish_reason ?? "unknown"}`);
|
|
384
431
|
if (toolCalls.length === 0)
|
|
385
|
-
return extractCompletionText(completion) ??
|
|
432
|
+
return extractCompletionText(completion) ?? NO_FINDINGS_SENTENCE;
|
|
386
433
|
messages.push({
|
|
387
434
|
role: "assistant",
|
|
388
435
|
content: msg.content ?? "",
|
|
@@ -449,7 +496,7 @@ async function runFileReviewWithTools(params) {
|
|
|
449
496
|
messages,
|
|
450
497
|
},
|
|
451
498
|
});
|
|
452
|
-
return extractCompletionText(final) ??
|
|
499
|
+
return extractCompletionText(final) ?? NO_FINDINGS_SENTENCE;
|
|
453
500
|
}
|
|
454
501
|
function draftHasStructuredFindings(consolidatedText) {
|
|
455
502
|
return /-\s*\[(?:high|medium)\]/i.test(consolidatedText);
|
|
@@ -590,9 +637,10 @@ async function runVerificationWithTools(params) {
|
|
|
590
637
|
});
|
|
591
638
|
}
|
|
592
639
|
export async function reviewMergeRequestMultiPass(params) {
|
|
593
|
-
const { openaiInstance, aiModel, promptLimits, changes, refs, gitLabProjectApiUrl, projectId, headers, maxFindings, reviewConcurrency, forceTools, loggers, debugDumpFile, debugRecordWriter, } = params;
|
|
640
|
+
const { openaiInstance, aiModel, promptLimits, triageDiffChars, changes, refs, gitLabProjectApiUrl, projectId, headers, maxFindings, reviewConcurrency, forceTools, promptProfile = DEFAULT_PROMPT_PROFILE, loggers, debugDumpFile, debugRecordWriter, } = params;
|
|
594
641
|
const { logStep } = loggers;
|
|
595
|
-
logStep(`Pass 1/4: triaging ${changes.length} file(s)`
|
|
642
|
+
logStep(`Pass 1/4: triaging ${changes.length} file(s) ` +
|
|
643
|
+
`(prompt profile=${promptProfile}, triage_diff_chars=${triageDiffChars})`);
|
|
596
644
|
const triageInputs = changes.map((c) => ({
|
|
597
645
|
path: c.new_path,
|
|
598
646
|
new_file: c.new_file,
|
|
@@ -600,7 +648,7 @@ export async function reviewMergeRequestMultiPass(params) {
|
|
|
600
648
|
renamed_file: c.renamed_file,
|
|
601
649
|
diff: c.diff,
|
|
602
650
|
}));
|
|
603
|
-
const triageMessages = buildTriagePrompt(triageInputs);
|
|
651
|
+
const triageMessages = buildTriagePrompt(triageInputs, promptProfile, triageDiffChars);
|
|
604
652
|
let triageResult = null;
|
|
605
653
|
let triageText = null;
|
|
606
654
|
let triageParseReason = null;
|
|
@@ -654,21 +702,35 @@ export async function reviewMergeRequestMultiPass(params) {
|
|
|
654
702
|
projectId,
|
|
655
703
|
headers,
|
|
656
704
|
forceTools,
|
|
705
|
+
promptProfile,
|
|
657
706
|
loggers,
|
|
658
707
|
debugDumpFile,
|
|
659
708
|
debugRecordWriter,
|
|
660
709
|
});
|
|
661
710
|
}
|
|
662
|
-
const
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
711
|
+
const { decisions, skipAllOverride, reviewFiles } = buildTriageDecisions({
|
|
712
|
+
changes,
|
|
713
|
+
triageResult,
|
|
714
|
+
});
|
|
715
|
+
const skippedCount = decisions.filter((d) => d.review_action === "skipped").length;
|
|
716
|
+
await appendDebugDump(debugDumpFile, debugRecordWriter, {
|
|
717
|
+
kind: "triage_decision",
|
|
718
|
+
summary: triageResult.summary,
|
|
719
|
+
skip_all_override: skipAllOverride,
|
|
720
|
+
triage_diff_chars: triageDiffChars,
|
|
721
|
+
files: decisions,
|
|
722
|
+
});
|
|
723
|
+
if (skipAllOverride) {
|
|
666
724
|
logStep(`Triage wanted to skip all ${changes.length} file(s) — overriding to review all. Summary: ${triageResult.summary.slice(0, 120)}...`);
|
|
667
|
-
reviewFiles = changes;
|
|
668
725
|
}
|
|
669
726
|
else {
|
|
670
727
|
logStep(`Triage: ${reviewFiles.length} file(s) to review, ${skippedCount} skipped. Summary: ${triageResult.summary.slice(0, 120)}...`);
|
|
671
728
|
}
|
|
729
|
+
for (const decision of decisions) {
|
|
730
|
+
if (decision.review_action === "review")
|
|
731
|
+
continue;
|
|
732
|
+
logStep(formatTriageDecisionLine(decision));
|
|
733
|
+
}
|
|
672
734
|
logStep(`Pass 2/4: reviewing ${reviewFiles.length} file(s) (concurrency=${reviewConcurrency})`);
|
|
673
735
|
const allChangedPaths = changes.map((c) => c.new_path);
|
|
674
736
|
const perFileFindings = await mapWithConcurrency(reviewFiles, reviewConcurrency, async (change) => {
|
|
@@ -685,6 +747,7 @@ export async function reviewMergeRequestMultiPass(params) {
|
|
|
685
747
|
projectId,
|
|
686
748
|
headers,
|
|
687
749
|
forceTools,
|
|
750
|
+
promptProfile,
|
|
688
751
|
loggers,
|
|
689
752
|
debugDumpFile,
|
|
690
753
|
debugRecordWriter,
|
|
@@ -696,10 +759,11 @@ export async function reviewMergeRequestMultiPass(params) {
|
|
|
696
759
|
perFileFindings,
|
|
697
760
|
summary: triageResult.summary,
|
|
698
761
|
maxFindings,
|
|
762
|
+
profile: promptProfile,
|
|
699
763
|
});
|
|
700
764
|
if (consolidateMessages == null) {
|
|
701
765
|
const DISCLAIMER = "This comment was generated by AI review bot.";
|
|
702
|
-
return
|
|
766
|
+
return `${NO_FINDINGS_SENTENCE}\n\n---\n_${DISCLAIMER}_`;
|
|
703
767
|
}
|
|
704
768
|
try {
|
|
705
769
|
const consolidateCompletion = await createCompletionWithDebug({
|
|
@@ -725,6 +789,7 @@ export async function reviewMergeRequestMultiPass(params) {
|
|
|
725
789
|
consolidatedFindings: consolidatedText,
|
|
726
790
|
maxFindings,
|
|
727
791
|
refs,
|
|
792
|
+
profile: promptProfile,
|
|
728
793
|
});
|
|
729
794
|
try {
|
|
730
795
|
const verificationCompletion = await runVerificationWithTools({
|
|
@@ -752,10 +817,9 @@ export async function reviewMergeRequestMultiPass(params) {
|
|
|
752
817
|
logStep(`Consolidation failed: ${error?.message ?? error}. Returning raw per-file findings.`);
|
|
753
818
|
const DISCLAIMER = "This comment was generated by AI review bot.";
|
|
754
819
|
const raw = perFileFindings
|
|
755
|
-
.filter((f) => !f.findings
|
|
756
|
-
!f.findings.includes("No confirmed bugs"))
|
|
820
|
+
.filter((f) => !isEmptyReviewBody(f.findings))
|
|
757
821
|
.map((f) => f.findings)
|
|
758
822
|
.join("\n");
|
|
759
|
-
return `${raw ||
|
|
823
|
+
return `${raw || NO_FINDINGS_SENTENCE}\n\n---\n_${DISCLAIMER}_`;
|
|
760
824
|
}
|
|
761
825
|
}
|
|
@@ -50,6 +50,58 @@ export async function renderDebugArtifactsHtml(params) {
|
|
|
50
50
|
return value;
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
|
+
function renderTriageDecisions() {
|
|
54
|
+
const decisionRecord = records.find((r) => r.kind === "triage_decision");
|
|
55
|
+
if (decisionRecord == null)
|
|
56
|
+
return "<pre>No triage decision record</pre>";
|
|
57
|
+
const summary = typeof decisionRecord.summary === "string" ? decisionRecord.summary : "";
|
|
58
|
+
const skipAllOverride = decisionRecord.skip_all_override === true;
|
|
59
|
+
const triageDiffChars = Number(decisionRecord.triage_diff_chars ?? 0);
|
|
60
|
+
const files = Array.isArray(decisionRecord.files) ? decisionRecord.files : [];
|
|
61
|
+
const actionLabel = (action) => {
|
|
62
|
+
switch (action) {
|
|
63
|
+
case "skipped":
|
|
64
|
+
return "Skipped";
|
|
65
|
+
case "reviewed_via_override":
|
|
66
|
+
return "Reviewed (override)";
|
|
67
|
+
case "review":
|
|
68
|
+
return "Review";
|
|
69
|
+
default:
|
|
70
|
+
return String(action ?? "unknown");
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const rows = files
|
|
74
|
+
.map((file) => {
|
|
75
|
+
const path = typeof file.path === "string" ? file.path : "?";
|
|
76
|
+
const verdict = typeof file.verdict === "string" ? file.verdict : "?";
|
|
77
|
+
const reason = typeof file.reason === "string" ? file.reason : "";
|
|
78
|
+
const action = actionLabel(file.review_action);
|
|
79
|
+
return `<tr>
|
|
80
|
+
<td><code>${escapeHtml(path)}</code></td>
|
|
81
|
+
<td>${escapeHtml(verdict)}</td>
|
|
82
|
+
<td>${escapeHtml(action)}</td>
|
|
83
|
+
<td>${escapeHtml(reason)}</td>
|
|
84
|
+
</tr>`;
|
|
85
|
+
})
|
|
86
|
+
.join("\n");
|
|
87
|
+
const overrideNote = skipAllOverride
|
|
88
|
+
? `<p class="tokens" style="margin:0 0 10px;color:var(--med);">All files were marked SKIP — pipeline overrode and reviewed every file.</p>`
|
|
89
|
+
: "";
|
|
90
|
+
return `${overrideNote}
|
|
91
|
+
<p class="tokens" style="margin:0 0 10px;">triage_diff_chars=${escapeHtml(String(triageDiffChars))}</p>
|
|
92
|
+
<p style="margin:0 0 12px;">${escapeHtml(summary)}</p>
|
|
93
|
+
<table style="width:100%;border-collapse:collapse;font-size:13px;">
|
|
94
|
+
<thead>
|
|
95
|
+
<tr style="text-align:left;color:var(--muted);">
|
|
96
|
+
<th style="padding:6px 8px;border-bottom:1px solid var(--line);">File</th>
|
|
97
|
+
<th style="padding:6px 8px;border-bottom:1px solid var(--line);">Verdict</th>
|
|
98
|
+
<th style="padding:6px 8px;border-bottom:1px solid var(--line);">Action</th>
|
|
99
|
+
<th style="padding:6px 8px;border-bottom:1px solid var(--line);">Reason</th>
|
|
100
|
+
</tr>
|
|
101
|
+
</thead>
|
|
102
|
+
<tbody>${rows}</tbody>
|
|
103
|
+
</table>`;
|
|
104
|
+
}
|
|
53
105
|
function renderFindings(markdown) {
|
|
54
106
|
const trimmed = markdown.trim();
|
|
55
107
|
if (trimmed === "")
|
|
@@ -144,7 +196,12 @@ export async function renderDebugArtifactsHtml(params) {
|
|
|
144
196
|
<div class="tokens" style="margin-top:6px;"><strong>Total:</strong> ${escapeHtml(totalTokens.toLocaleString())} tokens</div>
|
|
145
197
|
</div>
|
|
146
198
|
|
|
147
|
-
<h2>Pass 1 — Triage</h2>
|
|
199
|
+
<h2>Pass 1 — Triage Decisions</h2>
|
|
200
|
+
<div class="section">
|
|
201
|
+
${renderTriageDecisions()}
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
<h2>Pass 1 — Triage (raw model output)</h2>
|
|
148
205
|
<div class="section">
|
|
149
206
|
<div class="row"><span class="badge">label: triage_pass</span><span class="tokens">${escapeHtml(findTs("triage_pass"))}</span></div>
|
|
150
207
|
<pre>${escapeHtml(triageContentPretty)}</pre>
|
package/dist/cli.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import OpenAI from "openai";
|
|
4
4
|
import { readFile } from "node:fs/promises";
|
|
5
5
|
import { DEFAULT_MAX_FINDINGS, DEFAULT_REVIEW_CONCURRENCY, } from "./prompt/index.js";
|
|
6
|
-
import { envOrDefault, envOrUndefined, hasDebugFlag, hasForceToolsFlag, hasIncludeArtifactsFlag, hasIgnoredExtension, parseIgnoreExtensions, parseNumberFlag, parsePromptLimits, requireEnvs, } from "./cli/args.js";
|
|
6
|
+
import { envOrDefault, envOrUndefined, hasDebugFlag, hasForceToolsFlag, hasIncludeArtifactsFlag, hasIgnoredExtension, parseIgnoreExtensions, parseNumberFlag, parsePromptLimits, parseTriageDiffChars, readPromptProfileFromEnv, requireEnvs, } from "./cli/args.js";
|
|
7
7
|
import { reviewMergeRequestMultiPass } from "./cli/ci-review.js";
|
|
8
8
|
import { fetchMergeRequestChanges, postMergeRequestNote, } from "./gitlab/services.js";
|
|
9
9
|
import { renderDebugArtifactsHtml } from "./cli/debug-artifacts-html.js";
|
|
@@ -25,6 +25,7 @@ function printHelp() {
|
|
|
25
25
|
" --max-diffs=50",
|
|
26
26
|
" --max-diff-chars=16000",
|
|
27
27
|
" --max-total-prompt-chars=220000",
|
|
28
|
+
" --triage-diff-chars=2000 Max chars per file diff in triage pass (Pass 1).",
|
|
28
29
|
" --max-findings=5 Max findings in final review (CI multi-pass only).",
|
|
29
30
|
" --max-review-concurrency=5 Parallel per-file review calls (CI multi-pass only).",
|
|
30
31
|
"",
|
|
@@ -32,6 +33,9 @@ function printHelp() {
|
|
|
32
33
|
" OPENAI_API_KEY (required) OpenAI API key.",
|
|
33
34
|
" OPENAI_BASE_URL (optional) Custom OpenAI-compatible API base URL.",
|
|
34
35
|
" AI_MODEL (optional) OpenAI chat model, e.g. gpt-4o. Default: gpt-4o-mini.",
|
|
36
|
+
" AI_PROMPT_PROFILE (optional) Prompt style: \"default\" | \"weak\". Default: default.",
|
|
37
|
+
" Use \"weak\" for small/quantized models — shorter prompts,",
|
|
38
|
+
" positive rules, and few-shot examples.",
|
|
35
39
|
" PROJECT_ACCESS_TOKEN (optional) GitLab Project/Personal Access Token for API calls (required for most private repos; should have api scope).",
|
|
36
40
|
"",
|
|
37
41
|
"CI-only env vars (provided by GitLab):",
|
|
@@ -74,9 +78,12 @@ async function main() {
|
|
|
74
78
|
logStep(`gitlab-ai-review v${cliVersion}`);
|
|
75
79
|
const ignoredExtensions = parseIgnoreExtensions(process.argv);
|
|
76
80
|
const promptLimits = parsePromptLimits(process.argv);
|
|
81
|
+
const triageDiffChars = parseTriageDiffChars(process.argv);
|
|
77
82
|
const maxFindings = parseNumberFlag(process.argv, "max-findings", DEFAULT_MAX_FINDINGS, 1);
|
|
78
83
|
const reviewConcurrency = parseNumberFlag(process.argv, "max-review-concurrency", DEFAULT_REVIEW_CONCURRENCY, 1);
|
|
79
84
|
const aiModel = envOrDefault("AI_MODEL", "gpt-4o-mini");
|
|
85
|
+
const promptProfile = readPromptProfileFromEnv();
|
|
86
|
+
logStep(`Prompt profile: ${promptProfile}`);
|
|
80
87
|
const artifactHtmlFile = INCLUDE_ARTIFACTS
|
|
81
88
|
? envOrDefault("AI_REVIEW_ARTIFACT_HTML_FILE", "ai-review-report.html")
|
|
82
89
|
: undefined;
|
|
@@ -86,6 +93,7 @@ async function main() {
|
|
|
86
93
|
artifactRecords.push(record);
|
|
87
94
|
}
|
|
88
95
|
: undefined;
|
|
96
|
+
let artifactWritten = false;
|
|
89
97
|
const loggers = { logStep, logDebug };
|
|
90
98
|
const projectAccessToken = envOrUndefined("PROJECT_ACCESS_TOKEN") ?? envOrUndefined("GITLAB_TOKEN");
|
|
91
99
|
const gitlabRequired = [
|
|
@@ -106,58 +114,73 @@ async function main() {
|
|
|
106
114
|
headers["PRIVATE-TOKEN"] = projectAccessToken;
|
|
107
115
|
else
|
|
108
116
|
headers["JOB-TOKEN"] = envs["CI_JOB_TOKEN"];
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
(
|
|
120
|
-
!hasIgnoredExtension(change.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
promptLimits,
|
|
130
|
-
changes: filteredChanges,
|
|
131
|
-
refs: {
|
|
132
|
-
base: mrChanges.diff_refs?.base_sha ?? "HEAD",
|
|
133
|
-
head: mrChanges.diff_refs?.head_sha ?? "HEAD",
|
|
134
|
-
},
|
|
135
|
-
gitLabProjectApiUrl: new URL(`${ciApiV4Url}/projects/${projectId}`),
|
|
136
|
-
projectId,
|
|
137
|
-
headers,
|
|
138
|
-
maxFindings,
|
|
139
|
-
reviewConcurrency,
|
|
140
|
-
forceTools: FORCE_TOOLS,
|
|
141
|
-
loggers,
|
|
142
|
-
debugRecordWriter,
|
|
143
|
-
});
|
|
144
|
-
logStep("Posting AI review note to merge request");
|
|
145
|
-
const noteRes = await postMergeRequestNote({
|
|
146
|
-
gitLabBaseUrl: new URL(`${ciApiV4Url}/projects/${projectId}`),
|
|
147
|
-
headers,
|
|
148
|
-
mergeRequestIid,
|
|
149
|
-
}, { body: answer });
|
|
150
|
-
if (noteRes instanceof Error)
|
|
151
|
-
throw noteRes;
|
|
152
|
-
if (INCLUDE_ARTIFACTS && artifactHtmlFile != null) {
|
|
153
|
-
await renderDebugArtifactsHtml({
|
|
154
|
-
records: artifactRecords,
|
|
155
|
-
artifactHtmlFile,
|
|
156
|
-
cliVersion,
|
|
117
|
+
try {
|
|
118
|
+
logStep("Fetching merge request changes");
|
|
119
|
+
const mrChanges = await fetchMergeRequestChanges({
|
|
120
|
+
gitLabBaseUrl: new URL(ciApiV4Url),
|
|
121
|
+
headers,
|
|
122
|
+
projectId,
|
|
123
|
+
mergeRequestIid,
|
|
124
|
+
});
|
|
125
|
+
if (mrChanges instanceof Error)
|
|
126
|
+
throw mrChanges;
|
|
127
|
+
const filteredChanges = (mrChanges.changes ?? []).filter((change) => ignoredExtensions.length === 0 ||
|
|
128
|
+
(!hasIgnoredExtension(change.new_path, ignoredExtensions) &&
|
|
129
|
+
!hasIgnoredExtension(change.old_path, ignoredExtensions)));
|
|
130
|
+
if (filteredChanges.length === 0) {
|
|
131
|
+
process.stdout.write("No changes found in merge request. Skipping review.\n");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
logStep(`Requesting AI review with model: ${aiModel} (multi-pass pipeline)`);
|
|
135
|
+
const answer = await reviewMergeRequestMultiPass({
|
|
136
|
+
openaiInstance: new OpenAI({ apiKey: openaiApiKey }),
|
|
157
137
|
aiModel,
|
|
138
|
+
promptLimits,
|
|
139
|
+
triageDiffChars,
|
|
140
|
+
changes: filteredChanges,
|
|
141
|
+
refs: {
|
|
142
|
+
base: mrChanges.diff_refs?.base_sha ?? "HEAD",
|
|
143
|
+
head: mrChanges.diff_refs?.head_sha ?? "HEAD",
|
|
144
|
+
},
|
|
145
|
+
gitLabProjectApiUrl: new URL(`${ciApiV4Url}/projects/${projectId}`),
|
|
146
|
+
projectId,
|
|
147
|
+
headers,
|
|
148
|
+
maxFindings,
|
|
149
|
+
reviewConcurrency,
|
|
150
|
+
forceTools: FORCE_TOOLS,
|
|
151
|
+
promptProfile,
|
|
152
|
+
loggers,
|
|
153
|
+
debugRecordWriter,
|
|
158
154
|
});
|
|
155
|
+
logStep("Posting AI review note to merge request");
|
|
156
|
+
const noteRes = await postMergeRequestNote({
|
|
157
|
+
gitLabBaseUrl: new URL(`${ciApiV4Url}/projects/${projectId}`),
|
|
158
|
+
headers,
|
|
159
|
+
mergeRequestIid,
|
|
160
|
+
}, { body: answer });
|
|
161
|
+
if (noteRes instanceof Error)
|
|
162
|
+
throw noteRes;
|
|
163
|
+
process.stdout.write("Posted AI review comment to merge request.\n");
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
if (INCLUDE_ARTIFACTS && artifactHtmlFile != null && !artifactWritten) {
|
|
167
|
+
try {
|
|
168
|
+
await renderDebugArtifactsHtml({
|
|
169
|
+
records: artifactRecords,
|
|
170
|
+
artifactHtmlFile,
|
|
171
|
+
cliVersion,
|
|
172
|
+
aiModel,
|
|
173
|
+
});
|
|
174
|
+
artifactWritten = true;
|
|
175
|
+
}
|
|
176
|
+
catch (artifactError) {
|
|
177
|
+
const message = artifactError instanceof Error
|
|
178
|
+
? artifactError.message
|
|
179
|
+
: String(artifactError);
|
|
180
|
+
process.stderr.write(`Failed to write debug artifact "${artifactHtmlFile}": ${message}\n`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
159
183
|
}
|
|
160
|
-
process.stdout.write("Posted AI review comment to merge request.\n");
|
|
161
184
|
}
|
|
162
185
|
main().catch((err) => {
|
|
163
186
|
const message = err instanceof Error ? err.message : String(err);
|
package/dist/prompt/index.js
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
/** @format */
|
|
2
|
-
import {
|
|
3
|
-
import { normalizeReviewFindingsMarkdown, sanitizeGitLabMarkdown, truncateWithMarker, } from "./utils.js";
|
|
4
|
-
import {
|
|
2
|
+
import { buildFileReviewSystemMessage, buildMainSystemMessages, buildTriageSystemMessage, } from "./messages.js";
|
|
3
|
+
import { extractFirstJsonObject, normalizeReviewFindingsMarkdown, sanitizeGitLabMarkdown, truncateWithMarker, } from "./utils.js";
|
|
4
|
+
import { getConsolidateSystemLines, getVerificationSystemLines, } from "./templates/postprocess-system.js";
|
|
5
5
|
import { buildConsolidateUserContent, buildFileReviewUserContent, buildMainReviewUserContent, buildTriageUserContent, buildVerificationUserContent, } from "./templates/user-prompts.js";
|
|
6
|
+
import { DEFAULT_PROMPT_PROFILE, isEmptyReviewBody } from "./profile.js";
|
|
7
|
+
export { DEFAULT_PROMPT_PROFILE, NO_FINDINGS_SENTENCE, isEmptyReviewBody, parsePromptProfile, } from "./profile.js";
|
|
6
8
|
export const DEFAULT_PROMPT_LIMITS = {
|
|
7
9
|
maxDiffs: 50,
|
|
8
10
|
maxDiffChars: 16000,
|
|
9
11
|
maxTotalPromptChars: 220000,
|
|
10
12
|
};
|
|
11
|
-
|
|
13
|
+
/** Max chars of each file diff sent to the triage pass (Pass 1). */
|
|
14
|
+
export const DEFAULT_TRIAGE_DIFF_CHARS = 2000;
|
|
12
15
|
export const AI_MODEL_TEMPERATURE = 0.2;
|
|
13
16
|
export const AI_MAX_OUTPUT_TOKENS = 600;
|
|
14
|
-
export const buildPrompt = ({ changes, limits, allowTools = false, }) => {
|
|
17
|
+
export const buildPrompt = ({ changes, limits, allowTools = false, profile = DEFAULT_PROMPT_PROFILE, }) => {
|
|
15
18
|
const effectiveLimits = {
|
|
16
19
|
...DEFAULT_PROMPT_LIMITS,
|
|
17
20
|
...(limits ?? {}),
|
|
@@ -41,19 +44,22 @@ export const buildPrompt = ({ changes, limits, allowTools = false, }) => {
|
|
|
41
44
|
changesText,
|
|
42
45
|
});
|
|
43
46
|
const boundedContent = truncateWithMarker(userContent, effectiveLimits.maxTotalPromptChars, "prompt payload");
|
|
44
|
-
return [
|
|
47
|
+
return [
|
|
48
|
+
...buildMainSystemMessages(profile),
|
|
49
|
+
{ role: "user", content: boundedContent },
|
|
50
|
+
];
|
|
45
51
|
};
|
|
46
52
|
// ---------------------------------------------------------------------------
|
|
47
53
|
// Multi-pass pipeline prompts
|
|
48
54
|
// ---------------------------------------------------------------------------
|
|
49
55
|
export const DEFAULT_MAX_FINDINGS = 5;
|
|
50
56
|
export const DEFAULT_REVIEW_CONCURRENCY = 5;
|
|
51
|
-
export function buildTriagePrompt(changes) {
|
|
57
|
+
export function buildTriagePrompt(changes, profile = DEFAULT_PROMPT_PROFILE, triageDiffChars = DEFAULT_TRIAGE_DIFF_CHARS) {
|
|
52
58
|
return [
|
|
53
|
-
|
|
59
|
+
buildTriageSystemMessage(profile),
|
|
54
60
|
{
|
|
55
61
|
role: "user",
|
|
56
|
-
content: buildTriageUserContent(changes),
|
|
62
|
+
content: buildTriageUserContent(changes, triageDiffChars),
|
|
57
63
|
},
|
|
58
64
|
];
|
|
59
65
|
}
|
|
@@ -68,46 +74,67 @@ export function parseTriageResponseDetailed(text) {
|
|
|
68
74
|
if (cleaned === "") {
|
|
69
75
|
return { result: null, reason: "empty_response", parseError: null };
|
|
70
76
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
77
|
+
const tryParse = (src) => {
|
|
78
|
+
try {
|
|
79
|
+
return { parsed: JSON.parse(src), error: null };
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
const msg = e instanceof Error
|
|
83
|
+
? e.message
|
|
84
|
+
: typeof e === "string"
|
|
85
|
+
? e
|
|
86
|
+
: JSON.stringify(e);
|
|
87
|
+
return { parsed: null, error: msg };
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
let attempt = tryParse(cleaned);
|
|
91
|
+
// Some providers ignore JSON-mode and prefix prose like "Here is the JSON: {...}".
|
|
92
|
+
// Recover by extracting the first balanced {...} block and parsing that.
|
|
93
|
+
if (attempt.parsed == null) {
|
|
94
|
+
const extracted = extractFirstJsonObject(cleaned);
|
|
95
|
+
if (extracted != null && extracted !== cleaned) {
|
|
96
|
+
attempt = tryParse(extracted);
|
|
88
97
|
}
|
|
89
|
-
return { result: null, reason: "invalid_schema", parseError: null };
|
|
90
98
|
}
|
|
91
|
-
|
|
92
|
-
// JSON parse failure — caller will fall back to single-pass.
|
|
99
|
+
if (attempt.parsed == null || typeof attempt.parsed !== "object") {
|
|
93
100
|
return {
|
|
94
101
|
result: null,
|
|
95
102
|
reason: "invalid_json",
|
|
96
|
-
parseError: error
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
103
|
+
parseError: attempt.error,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
const parsed = attempt.parsed;
|
|
107
|
+
if (typeof parsed.summary === "string" && Array.isArray(parsed.files)) {
|
|
108
|
+
return {
|
|
109
|
+
result: {
|
|
110
|
+
summary: parsed.summary,
|
|
111
|
+
files: parsed.files
|
|
112
|
+
.filter((f) => typeof f === "object" &&
|
|
113
|
+
f != null &&
|
|
114
|
+
typeof f.path === "string" &&
|
|
115
|
+
(f.verdict === "NEEDS_REVIEW" ||
|
|
116
|
+
f.verdict === "SKIP"))
|
|
117
|
+
.map((f) => ({
|
|
118
|
+
path: f.path,
|
|
119
|
+
verdict: f.verdict,
|
|
120
|
+
...(typeof f.reason === "string" && f.reason.trim() !== ""
|
|
121
|
+
? { reason: f.reason.trim() }
|
|
122
|
+
: {}),
|
|
123
|
+
})),
|
|
124
|
+
},
|
|
125
|
+
reason: null,
|
|
126
|
+
parseError: null,
|
|
101
127
|
};
|
|
102
128
|
}
|
|
129
|
+
return { result: null, reason: "invalid_schema", parseError: null };
|
|
103
130
|
}
|
|
104
131
|
export function buildFileReviewPrompt(params) {
|
|
105
|
-
const { filePath, fileDiff, summary, otherChangedFiles, allowTools = false, } = params;
|
|
132
|
+
const { filePath, fileDiff, summary, otherChangedFiles, allowTools = false, profile = DEFAULT_PROMPT_PROFILE, } = params;
|
|
106
133
|
const toolNote = allowTools
|
|
107
134
|
? "Tools (get_file_at_ref, grep_repository) are available to verify suspicions or read the full file."
|
|
108
135
|
: "Tools are unavailable; rely only on visible diff evidence.";
|
|
109
136
|
return [
|
|
110
|
-
|
|
137
|
+
buildFileReviewSystemMessage(profile),
|
|
111
138
|
{
|
|
112
139
|
role: "user",
|
|
113
140
|
content: buildFileReviewUserContent({
|
|
@@ -121,9 +148,8 @@ export function buildFileReviewPrompt(params) {
|
|
|
121
148
|
];
|
|
122
149
|
}
|
|
123
150
|
export function buildConsolidatePrompt(params) {
|
|
124
|
-
const { perFileFindings, summary, maxFindings } = params;
|
|
125
|
-
const meaningful = perFileFindings.filter((f) => !f.findings
|
|
126
|
-
!f.findings.includes("No confirmed bugs"));
|
|
151
|
+
const { perFileFindings, summary, maxFindings, profile = DEFAULT_PROMPT_PROFILE, } = params;
|
|
152
|
+
const meaningful = perFileFindings.filter((f) => !isEmptyReviewBody(f.findings));
|
|
127
153
|
if (meaningful.length === 0)
|
|
128
154
|
return null;
|
|
129
155
|
const findingsText = meaningful
|
|
@@ -132,7 +158,7 @@ export function buildConsolidatePrompt(params) {
|
|
|
132
158
|
return [
|
|
133
159
|
{
|
|
134
160
|
role: "system",
|
|
135
|
-
content:
|
|
161
|
+
content: getConsolidateSystemLines(profile, maxFindings).join("\n"),
|
|
136
162
|
},
|
|
137
163
|
{
|
|
138
164
|
role: "user",
|
|
@@ -145,14 +171,14 @@ export function buildConsolidatePrompt(params) {
|
|
|
145
171
|
];
|
|
146
172
|
}
|
|
147
173
|
export function buildVerificationPrompt(params) {
|
|
148
|
-
const { perFileFindings, summary, consolidatedFindings, maxFindings, refs } = params;
|
|
174
|
+
const { perFileFindings, summary, consolidatedFindings, maxFindings, refs, profile = DEFAULT_PROMPT_PROFILE, } = params;
|
|
149
175
|
const findingsText = perFileFindings
|
|
150
176
|
.map((f) => `### ${f.path}\n${f.findings}`)
|
|
151
177
|
.join("\n\n");
|
|
152
178
|
return [
|
|
153
179
|
{
|
|
154
180
|
role: "system",
|
|
155
|
-
content:
|
|
181
|
+
content: getVerificationSystemLines(profile, maxFindings).join("\n"),
|
|
156
182
|
},
|
|
157
183
|
{
|
|
158
184
|
role: "user",
|
package/dist/prompt/messages.js
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
/** @format */
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
2
|
+
import { getFileReviewSystemLines, getMainSystemLines, } from "./templates/review-system.js";
|
|
3
|
+
import { getTriageSystemLines } from "./templates/triage-system.js";
|
|
4
|
+
import { DEFAULT_PROMPT_PROFILE } from "./profile.js";
|
|
5
|
+
export const buildMainSystemMessages = (profile = DEFAULT_PROMPT_PROFILE) => [
|
|
5
6
|
{
|
|
6
7
|
role: "system",
|
|
7
|
-
content:
|
|
8
|
+
content: getMainSystemLines(profile).join("\n"),
|
|
8
9
|
},
|
|
9
10
|
];
|
|
10
|
-
export const
|
|
11
|
+
export const buildTriageSystemMessage = (profile = DEFAULT_PROMPT_PROFILE) => ({
|
|
11
12
|
role: "system",
|
|
12
|
-
content:
|
|
13
|
-
};
|
|
14
|
-
export const
|
|
13
|
+
content: getTriageSystemLines(profile).join("\n"),
|
|
14
|
+
});
|
|
15
|
+
export const buildFileReviewSystemMessage = (profile = DEFAULT_PROMPT_PROFILE) => ({
|
|
15
16
|
role: "system",
|
|
16
|
-
content:
|
|
17
|
-
};
|
|
17
|
+
content: getFileReviewSystemLines(profile).join("\n"),
|
|
18
|
+
});
|
|
19
|
+
/** @deprecated kept for backward compatibility — use buildTriageSystemMessage(). */
|
|
20
|
+
export const TRIAGE_SYSTEM = buildTriageSystemMessage();
|
|
21
|
+
/** @deprecated kept for backward compatibility — use buildFileReviewSystemMessage(). */
|
|
22
|
+
export const FILE_REVIEW_SYSTEM = buildFileReviewSystemMessage();
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** @format */
|
|
2
|
+
export const DEFAULT_PROMPT_PROFILE = "default";
|
|
3
|
+
/** Canonical phrase the model is asked to emit when no findings survive
|
|
4
|
+
* review/verification. Parsers are tolerant of common synonyms. */
|
|
5
|
+
export const NO_FINDINGS_SENTENCE = "No confirmed bugs or high-value optimizations found.";
|
|
6
|
+
const NO_FINDINGS_PATTERNS = [
|
|
7
|
+
/\bno\s+(?:confirmed\s+)?(?:bugs?|issues?|findings?|defects?|problems?)\b[^.\n]{0,80}\b(?:found|detected|identified|reported)\b/i,
|
|
8
|
+
/\bnothing\s+to\s+report\b/i,
|
|
9
|
+
/\bno\s+high[- ]value\s+optimizations?\s+found\b/i,
|
|
10
|
+
/\ball\s+clear\b/i,
|
|
11
|
+
];
|
|
12
|
+
const SEVERITY_HEADER_PATTERN = /\[(?:high|medium)\]/i;
|
|
13
|
+
/** Returns true when the model emitted a "no findings" sentinel (or a common
|
|
14
|
+
* synonym) and there are no severity bullets in the body. Severity bullets
|
|
15
|
+
* always win — a real finding is never treated as empty. */
|
|
16
|
+
export function isEmptyReviewBody(text) {
|
|
17
|
+
const trimmed = text.trim();
|
|
18
|
+
if (trimmed === "")
|
|
19
|
+
return true;
|
|
20
|
+
if (SEVERITY_HEADER_PATTERN.test(trimmed))
|
|
21
|
+
return false;
|
|
22
|
+
return NO_FINDINGS_PATTERNS.some((re) => re.test(trimmed));
|
|
23
|
+
}
|
|
24
|
+
export function parsePromptProfile(value) {
|
|
25
|
+
const v = (value ?? "").trim().toLowerCase();
|
|
26
|
+
if (v === "weak")
|
|
27
|
+
return "weak";
|
|
28
|
+
return "default";
|
|
29
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** @format */
|
|
2
|
-
|
|
2
|
+
import { NO_FINDINGS_SENTENCE } from "../profile.js";
|
|
3
|
+
function buildConsolidateDefaultLines(maxFindings) {
|
|
3
4
|
return [
|
|
4
5
|
"You consolidate per-file code review findings into a final ranked list.",
|
|
5
6
|
`Select the top ${maxFindings} most important findings. Deduplicate overlapping issues.`,
|
|
@@ -12,11 +13,43 @@ export function buildConsolidateSystemLines(maxFindings) {
|
|
|
12
13
|
"Do not add new findings. Do not add headings, summaries, or commentary.",
|
|
13
14
|
"Drop any finding that: claims a syntax error without quoting the invalid token, claims a function/variable is missing without concrete proof, or flags a refactoring rename as a bug.",
|
|
14
15
|
`If fewer than ${maxFindings} findings exist, return all of them.`,
|
|
15
|
-
|
|
16
|
+
`If all findings are low quality or dubious after dedup, return exactly: "${NO_FINDINGS_SENTENCE}"`,
|
|
16
17
|
"GitLab-flavoured markdown.",
|
|
17
18
|
];
|
|
18
19
|
}
|
|
19
|
-
|
|
20
|
+
function buildConsolidateWeakLines(maxFindings) {
|
|
21
|
+
return [
|
|
22
|
+
"You merge per-file review findings into one ranked list.",
|
|
23
|
+
"Output bullets only — no preamble, no headings, no code fences.",
|
|
24
|
+
`Keep at most ${maxFindings} findings. Do not invent new ones.`,
|
|
25
|
+
"",
|
|
26
|
+
"Steps:",
|
|
27
|
+
"1. Drop duplicates and findings that are reworded versions of each other (keep the clearest).",
|
|
28
|
+
"2. Drop a finding if it has no concrete evidence (no file/line, vague reasoning, or just \"this might be wrong\").",
|
|
29
|
+
"3. Sort the remainder: all [high] before any [medium]; within a tier, correctness bugs before security before perf.",
|
|
30
|
+
"4. Take the top items up to the limit.",
|
|
31
|
+
"",
|
|
32
|
+
"Output format — copy spacing and case exactly, do not wrap bullets in backticks:",
|
|
33
|
+
"- [high|medium] <short title>",
|
|
34
|
+
" File: <path>",
|
|
35
|
+
" Line: ~<N>",
|
|
36
|
+
" Why: <one sentence pointing at the evidence>",
|
|
37
|
+
"",
|
|
38
|
+
"Example output (two findings, ranked):",
|
|
39
|
+
"- [high] Null pointer dereference in user lookup",
|
|
40
|
+
" File: src/user/service.ts",
|
|
41
|
+
" Line: ~42",
|
|
42
|
+
" Why: getUser() can return null but the next line calls user.id without a guard.",
|
|
43
|
+
"",
|
|
44
|
+
"- [medium] Off-by-one in pagination loop",
|
|
45
|
+
" File: src/api/list.ts",
|
|
46
|
+
" Line: ~88",
|
|
47
|
+
" Why: loop runs while i <= total but indices are 0-based, so the last item is read past the array end.",
|
|
48
|
+
"",
|
|
49
|
+
`If after dedup nothing survives, output exactly this single line and nothing else: ${NO_FINDINGS_SENTENCE}`,
|
|
50
|
+
];
|
|
51
|
+
}
|
|
52
|
+
function buildVerificationDefaultLines(maxFindings) {
|
|
20
53
|
return [
|
|
21
54
|
"You are a skeptical verifier of a merge request review.",
|
|
22
55
|
"Your job is to remove weak, speculative, or unsupported findings from the draft list.",
|
|
@@ -32,7 +65,54 @@ export function buildVerificationSystemLines(maxFindings) {
|
|
|
32
65
|
"` Why: <one concise sentence with key evidence>`",
|
|
33
66
|
"Do not add headings, summaries, or extra commentary.",
|
|
34
67
|
`Return at most ${maxFindings} findings.`,
|
|
35
|
-
|
|
68
|
+
`If no findings survive verification, return exactly: "${NO_FINDINGS_SENTENCE}"`,
|
|
36
69
|
"GitLab-flavoured markdown.",
|
|
37
70
|
];
|
|
38
71
|
}
|
|
72
|
+
function buildVerificationWeakLines(maxFindings) {
|
|
73
|
+
return [
|
|
74
|
+
"You verify a draft list of merge-request review findings against the actual repository.",
|
|
75
|
+
"Output bullets only — no preamble, no headings, no code fences. Do not invent new findings.",
|
|
76
|
+
`Return at most ${maxFindings} findings.`,
|
|
77
|
+
"",
|
|
78
|
+
"For each draft finding, follow these steps in order:",
|
|
79
|
+
"1. Look it up in the per-file evidence pool. If it is not in the evidence pool, drop it.",
|
|
80
|
+
"2. If the finding makes a claim about code that exists at refs.head, call get_file_at_ref(path, refs.head) and confirm the offending text really sits at the cited line (±10 lines is fine).",
|
|
81
|
+
"3. If the file contents at refs.head do not contain the offending text, or contain a corrected version of it, drop the finding.",
|
|
82
|
+
"4. If you cannot confirm the claim with one or two tool calls, drop the finding.",
|
|
83
|
+
"",
|
|
84
|
+
"Keep a finding only after step 3 succeeds. Otherwise drop it.",
|
|
85
|
+
"",
|
|
86
|
+
"Output format — copy spacing and case exactly, do not wrap bullets in backticks:",
|
|
87
|
+
"- [high|medium] <short title>",
|
|
88
|
+
" File: <path>",
|
|
89
|
+
" Line: ~<N>",
|
|
90
|
+
" Why: <one sentence pointing at the evidence you confirmed>",
|
|
91
|
+
"",
|
|
92
|
+
"Example — KEEP this finding because get_file_at_ref shows the cited code at refs.head:",
|
|
93
|
+
"- [high] Null pointer dereference in user lookup",
|
|
94
|
+
" File: src/user/service.ts",
|
|
95
|
+
" Line: ~42",
|
|
96
|
+
" Why: getUser() returns null on miss and the next line calls user.id without a guard (confirmed in src/user/service.ts at refs.head).",
|
|
97
|
+
"",
|
|
98
|
+
"Example — DROP a finding when refs.head shows the bug is already gone (do not output it).",
|
|
99
|
+
"",
|
|
100
|
+
`If after verification nothing survives, output exactly this single line and nothing else: ${NO_FINDINGS_SENTENCE}`,
|
|
101
|
+
];
|
|
102
|
+
}
|
|
103
|
+
export function buildConsolidateSystemLines(maxFindings) {
|
|
104
|
+
return buildConsolidateDefaultLines(maxFindings);
|
|
105
|
+
}
|
|
106
|
+
export function buildVerificationSystemLines(maxFindings) {
|
|
107
|
+
return buildVerificationDefaultLines(maxFindings);
|
|
108
|
+
}
|
|
109
|
+
export function getConsolidateSystemLines(profile, maxFindings) {
|
|
110
|
+
return profile === "weak"
|
|
111
|
+
? buildConsolidateWeakLines(maxFindings)
|
|
112
|
+
: buildConsolidateDefaultLines(maxFindings);
|
|
113
|
+
}
|
|
114
|
+
export function getVerificationSystemLines(profile, maxFindings) {
|
|
115
|
+
return profile === "weak"
|
|
116
|
+
? buildVerificationWeakLines(maxFindings)
|
|
117
|
+
: buildVerificationDefaultLines(maxFindings);
|
|
118
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** @format */
|
|
2
|
-
|
|
2
|
+
import { NO_FINDINGS_SENTENCE } from "../profile.js";
|
|
3
|
+
const MAIN_DEFAULT_LINES = [
|
|
3
4
|
"You are an AI code reviewer for pull requests.",
|
|
4
5
|
"Find only real bugs introduced by the diff.",
|
|
5
6
|
"Return at most 3 findings. Prefer no finding over a weak one.",
|
|
@@ -9,7 +10,7 @@ export const MAIN_SYSTEM_LINES = [
|
|
|
9
10
|
"- Ignore style, refactoring suggestions, and general best practices.",
|
|
10
11
|
"- If uncertain, use tools: get_file_at_ref and grep_repository.",
|
|
11
12
|
"- Report only issues clearly visible in diff or verified by tools.",
|
|
12
|
-
|
|
13
|
+
`- If uncertain after checking, return exactly: "${NO_FINDINGS_SENTENCE}"`,
|
|
13
14
|
"",
|
|
14
15
|
"Severity:",
|
|
15
16
|
"- [high]: deterministic runtime/security breakage with clear path.",
|
|
@@ -20,9 +21,40 @@ export const MAIN_SYSTEM_LINES = [
|
|
|
20
21
|
"- ` File: <path>`",
|
|
21
22
|
"- ` Line: ~<N>`",
|
|
22
23
|
"- ` Why: <one concise sentence with evidence>`",
|
|
23
|
-
|
|
24
|
+
`- If no issues: exactly "${NO_FINDINGS_SENTENCE}"`,
|
|
24
25
|
];
|
|
25
|
-
|
|
26
|
+
const MAIN_WEAK_LINES = [
|
|
27
|
+
"You are an AI bug-finder for a merge request.",
|
|
28
|
+
"Output bullets only — no preamble, no headings, no code fences, no closing remarks.",
|
|
29
|
+
"Return at most 3 findings.",
|
|
30
|
+
"",
|
|
31
|
+
"Report a finding ONLY when ALL of these are true:",
|
|
32
|
+
"1. The bug is on a line added or modified in the diff (or a direct consequence of one).",
|
|
33
|
+
"2. You can quote the offending text from the diff or from get_file_at_ref output.",
|
|
34
|
+
"3. The bug causes wrong behavior, a crash, a security issue, or data loss — not a style nit.",
|
|
35
|
+
"If any of those is not true, do not include the finding.",
|
|
36
|
+
"",
|
|
37
|
+
"When the diff is unclear, call the tools: get_file_at_ref(path, ref) or grep_repository(query, ref). Prefer the head ref.",
|
|
38
|
+
"",
|
|
39
|
+
"Severity tags:",
|
|
40
|
+
"- [high] = will break at runtime or leak data.",
|
|
41
|
+
"- [medium] = strong evidence of a bug, but conditions matter.",
|
|
42
|
+
"",
|
|
43
|
+
"Output format — copy spacing and case exactly, do not wrap in backticks or fences:",
|
|
44
|
+
"- [high|medium] <short title>",
|
|
45
|
+
" File: <path>",
|
|
46
|
+
" Line: ~<N>",
|
|
47
|
+
" Why: <one sentence pointing at the evidence>",
|
|
48
|
+
"",
|
|
49
|
+
"Example of a valid finding:",
|
|
50
|
+
"- [high] Null pointer dereference in user lookup",
|
|
51
|
+
" File: src/user/service.ts",
|
|
52
|
+
" Line: ~42",
|
|
53
|
+
" Why: getUser() can return null but the next line calls user.id without a guard.",
|
|
54
|
+
"",
|
|
55
|
+
`If you cannot meet all three conditions for any finding, output exactly this single line and nothing else: ${NO_FINDINGS_SENTENCE}`,
|
|
56
|
+
];
|
|
57
|
+
const FILE_DEFAULT_LINES = [
|
|
26
58
|
"You are an AI reviewer for a single-file diff.",
|
|
27
59
|
"Find only real bugs introduced by changed lines.",
|
|
28
60
|
"Return at most 2 findings. Prefer no finding over a weak one.",
|
|
@@ -32,7 +64,7 @@ export const FILE_REVIEW_SYSTEM_LINES = [
|
|
|
32
64
|
"- Ignore style/refactor/general improvement comments.",
|
|
33
65
|
"- If uncertain, use tools: get_file_at_ref and grep_repository.",
|
|
34
66
|
"- Report only issues clearly visible in diff or verified by tools.",
|
|
35
|
-
|
|
67
|
+
`- If uncertain after checking, return exactly: "${NO_FINDINGS_SENTENCE}"`,
|
|
36
68
|
"",
|
|
37
69
|
"Severity:",
|
|
38
70
|
"- [high]: deterministic runtime/security breakage with clear path.",
|
|
@@ -43,5 +75,46 @@ export const FILE_REVIEW_SYSTEM_LINES = [
|
|
|
43
75
|
"- ` File: <path>`",
|
|
44
76
|
"- ` Line: ~<N>`",
|
|
45
77
|
"- ` Why: <one concise sentence with evidence>`",
|
|
46
|
-
|
|
78
|
+
`- If no issues: exactly "${NO_FINDINGS_SENTENCE}"`,
|
|
79
|
+
];
|
|
80
|
+
const FILE_WEAK_LINES = [
|
|
81
|
+
"You are an AI bug-finder reviewing one file's diff.",
|
|
82
|
+
"Output bullets only — no preamble, no headings, no code fences.",
|
|
83
|
+
"Return at most 2 findings.",
|
|
84
|
+
"",
|
|
85
|
+
"Report a finding ONLY when ALL of these are true:",
|
|
86
|
+
"1. The bug is on a line added or modified in this file's diff.",
|
|
87
|
+
"2. You can quote the offending text from the diff or from get_file_at_ref output.",
|
|
88
|
+
"3. The bug causes wrong behavior, a crash, a security issue, or data loss.",
|
|
89
|
+
"If any of those is not true, do not include the finding.",
|
|
90
|
+
"",
|
|
91
|
+
"When the diff is unclear, call the tools: get_file_at_ref(path, ref) or grep_repository(query, ref). Prefer the head ref.",
|
|
92
|
+
"",
|
|
93
|
+
"Severity tags:",
|
|
94
|
+
"- [high] = will break at runtime or leak data.",
|
|
95
|
+
"- [medium] = strong evidence of a bug, but conditions matter.",
|
|
96
|
+
"",
|
|
97
|
+
"Output format — copy spacing and case exactly, do not wrap in backticks or fences:",
|
|
98
|
+
"- [high|medium] <short title>",
|
|
99
|
+
" File: <path>",
|
|
100
|
+
" Line: ~<N>",
|
|
101
|
+
" Why: <one sentence pointing at the evidence>",
|
|
102
|
+
"",
|
|
103
|
+
"Example of a valid finding:",
|
|
104
|
+
"- [medium] Off-by-one in pagination loop",
|
|
105
|
+
" File: src/api/list.ts",
|
|
106
|
+
" Line: ~88",
|
|
107
|
+
" Why: loop runs while i <= total but indices are 0-based, so the last item is read past the array end.",
|
|
108
|
+
"",
|
|
109
|
+
`If you cannot meet all three conditions for any finding, output exactly this single line and nothing else: ${NO_FINDINGS_SENTENCE}`,
|
|
47
110
|
];
|
|
111
|
+
export function getMainSystemLines(profile) {
|
|
112
|
+
return profile === "weak" ? MAIN_WEAK_LINES : MAIN_DEFAULT_LINES;
|
|
113
|
+
}
|
|
114
|
+
export function getFileReviewSystemLines(profile) {
|
|
115
|
+
return profile === "weak" ? FILE_WEAK_LINES : FILE_DEFAULT_LINES;
|
|
116
|
+
}
|
|
117
|
+
/** @deprecated kept for backward compatibility — use getMainSystemLines("default"). */
|
|
118
|
+
export const MAIN_SYSTEM_LINES = MAIN_DEFAULT_LINES;
|
|
119
|
+
/** @deprecated kept for backward compatibility — use getFileReviewSystemLines("default"). */
|
|
120
|
+
export const FILE_REVIEW_SYSTEM_LINES = FILE_DEFAULT_LINES;
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/** @format */
|
|
2
|
-
|
|
2
|
+
const DEFAULT_LINES = [
|
|
3
3
|
"You are a senior developer triaging files in a merge request.",
|
|
4
4
|
"For each file, decide whether it NEEDS_REVIEW (modifies logic, functionality, security, or performance) or can be SKIPPED (cosmetic-only: formatting, comments, renaming for clarity, docs, auto-generated files).",
|
|
5
5
|
"",
|
|
6
6
|
"Also produce a concise summary (2-4 sentences) of the entire merge request: what it does and which areas it touches.",
|
|
7
7
|
"",
|
|
8
|
-
"Respond with a JSON object (no markdown fences) in this exact schema:",
|
|
9
|
-
'{ "summary": "<MR summary>", "files": [{ "path": "<file path>", "verdict": "NEEDS_REVIEW" | "SKIP" }] }',
|
|
8
|
+
"Respond with a JSON object (no markdown fences, no prose before or after) in this exact schema:",
|
|
9
|
+
'{ "summary": "<MR summary>", "files": [{ "path": "<file path>", "verdict": "NEEDS_REVIEW" | "SKIP", "reason": "<one short sentence explaining the verdict>" }] }',
|
|
10
|
+
"",
|
|
11
|
+
"The reason field is required for every file — cite what you saw in the diff (e.g. \"import reordering only\" or \"changes Input props and validation\").",
|
|
10
12
|
"",
|
|
11
13
|
"Rules:",
|
|
12
14
|
"- When in doubt, verdict is NEEDS_REVIEW.",
|
|
@@ -18,4 +20,31 @@ export const TRIAGE_SYSTEM_LINES = [
|
|
|
18
20
|
"- Test files that add, remove, or change assertions or expected values are NEEDS_REVIEW.",
|
|
19
21
|
"- Test-only files are SKIP only if changes are purely cosmetic (formatting, comments).",
|
|
20
22
|
"- Config/CI/docs files are SKIP unless they modify build targets, env vars, or secrets.",
|
|
23
|
+
"",
|
|
24
|
+
"Example output (copy this style exactly — single JSON object, no fences, no preamble):",
|
|
25
|
+
'{"summary":"Adds retry-with-backoff to the OpenAI client and updates the README badge.","files":[{"path":"src/openai/client.ts","verdict":"NEEDS_REVIEW","reason":"adds retry loop and error handling in API client"},{"path":"README.md","verdict":"SKIP","reason":"badge URL change only"}]}',
|
|
26
|
+
];
|
|
27
|
+
const WEAK_LINES = [
|
|
28
|
+
"You triage merge-request files. Output one JSON object only — no prose, no markdown, no code fences, no preamble.",
|
|
29
|
+
"",
|
|
30
|
+
"Schema (use exactly these keys):",
|
|
31
|
+
'{"summary":"<2-3 sentences about the MR>","files":[{"path":"<file path>","verdict":"NEEDS_REVIEW" | "SKIP","reason":"<one short sentence>"}]}',
|
|
32
|
+
"",
|
|
33
|
+
"Mark a file NEEDS_REVIEW when ANY of these is true:",
|
|
34
|
+
"- the diff adds or removes non-comment lines,",
|
|
35
|
+
"- the diff changes a function signature, return type, control flow, or variable value,",
|
|
36
|
+
"- the file is new and contains code,",
|
|
37
|
+
"- a test changes an assertion or expected value,",
|
|
38
|
+
"- a config/CI file changes build targets, env vars, or secrets.",
|
|
39
|
+
"",
|
|
40
|
+
"Mark a file SKIP only when the diff is purely cosmetic: whitespace, comments, JSDoc, plain docs, auto-generated files, or deletions that cannot affect callers.",
|
|
41
|
+
"When in doubt, choose NEEDS_REVIEW.",
|
|
42
|
+
"",
|
|
43
|
+
"Example output (copy this exactly — one line, one JSON object, nothing else):",
|
|
44
|
+
'{"summary":"Adds retry-with-backoff to the OpenAI client and updates the README badge.","files":[{"path":"src/openai/client.ts","verdict":"NEEDS_REVIEW","reason":"adds retry loop in API client"},{"path":"README.md","verdict":"SKIP","reason":"badge URL only"}]}',
|
|
21
45
|
];
|
|
46
|
+
export function getTriageSystemLines(profile) {
|
|
47
|
+
return profile === "weak" ? WEAK_LINES : DEFAULT_LINES;
|
|
48
|
+
}
|
|
49
|
+
/** @deprecated kept for backward compatibility — use getTriageSystemLines("default"). */
|
|
50
|
+
export const TRIAGE_SYSTEM_LINES = DEFAULT_LINES;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** @format */
|
|
2
|
+
import { truncateWithMarker } from "../utils.js";
|
|
2
3
|
export function buildMainReviewUserContent(params) {
|
|
3
4
|
const { stats, toolNote, changesText } = params;
|
|
4
5
|
return [
|
|
@@ -11,7 +12,7 @@ export function buildMainReviewUserContent(params) {
|
|
|
11
12
|
"Produce your review now. Follow the system instructions strictly.",
|
|
12
13
|
].join("\n");
|
|
13
14
|
}
|
|
14
|
-
export function buildTriageUserContent(changes) {
|
|
15
|
+
export function buildTriageUserContent(changes, maxDiffChars) {
|
|
15
16
|
const fileEntries = changes.map((c) => {
|
|
16
17
|
const flags = [];
|
|
17
18
|
if (c.new_file)
|
|
@@ -21,9 +22,8 @@ export function buildTriageUserContent(changes) {
|
|
|
21
22
|
if (c.renamed_file)
|
|
22
23
|
flags.push("renamed");
|
|
23
24
|
const flagStr = flags.length > 0 ? ` [${flags.join(", ")}]` : "";
|
|
24
|
-
const snippet = c.diff
|
|
25
|
-
|
|
26
|
-
return `### ${c.path}${flagStr}\n\`\`\`\n${snippet}${truncNote}\n\`\`\``;
|
|
25
|
+
const snippet = truncateWithMarker(c.diff, maxDiffChars, c.path);
|
|
26
|
+
return `### ${c.path}${flagStr}\n\`\`\`diff\n${snippet}\n\`\`\``;
|
|
27
27
|
});
|
|
28
28
|
return `Triage these ${changes.length} file(s):\n\n${fileEntries.join("\n\n")}`;
|
|
29
29
|
}
|
package/dist/prompt/utils.js
CHANGED
|
@@ -1,4 +1,46 @@
|
|
|
1
1
|
/** @format */
|
|
2
|
+
/** Extract the first balanced JSON object substring from arbitrary text.
|
|
3
|
+
* Useful when an LLM wraps JSON in prose ("Here is the result: { ... }").
|
|
4
|
+
* Returns null if no balanced object is found. */
|
|
5
|
+
export function extractFirstJsonObject(input) {
|
|
6
|
+
let depth = 0;
|
|
7
|
+
let start = -1;
|
|
8
|
+
let inString = false;
|
|
9
|
+
let escape = false;
|
|
10
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
11
|
+
const ch = input[i];
|
|
12
|
+
if (inString) {
|
|
13
|
+
if (escape) {
|
|
14
|
+
escape = false;
|
|
15
|
+
}
|
|
16
|
+
else if (ch === "\\") {
|
|
17
|
+
escape = true;
|
|
18
|
+
}
|
|
19
|
+
else if (ch === '"') {
|
|
20
|
+
inString = false;
|
|
21
|
+
}
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (ch === '"') {
|
|
25
|
+
inString = true;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (ch === "{") {
|
|
29
|
+
if (depth === 0)
|
|
30
|
+
start = i;
|
|
31
|
+
depth += 1;
|
|
32
|
+
}
|
|
33
|
+
else if (ch === "}") {
|
|
34
|
+
if (depth === 0)
|
|
35
|
+
continue;
|
|
36
|
+
depth -= 1;
|
|
37
|
+
if (depth === 0 && start >= 0) {
|
|
38
|
+
return input.slice(start, i + 1);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
2
44
|
export function truncateWithMarker(value, maxChars, markerLabel) {
|
|
3
45
|
if (value.length <= maxChars)
|
|
4
46
|
return value;
|