docgrity 0.1.2 → 0.1.3

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.
Files changed (54) hide show
  1. package/README.md +80 -127
  2. package/package.json +31 -164
  3. package/.github/workflows/ci.yml +0 -54
  4. package/.vscodeignore +0 -13
  5. package/action/LICENSE +0 -21
  6. package/action/README.md +0 -104
  7. package/action/examples/docgrity.yml +0 -61
  8. package/action/package.json +0 -38
  9. package/action/test/corpus.test.mjs +0 -59
  10. package/action/test/issues.test.mjs +0 -89
  11. package/action/test/report.test.mjs +0 -76
  12. package/docgrity_logo.png +0 -0
  13. package/image.png +0 -0
  14. package/media/icon.png +0 -0
  15. package/media/icon.svg +0 -5
  16. package/samples/api-limits.md +0 -23
  17. package/samples/architecture-notes.md +0 -28
  18. package/samples/deployment-guide.md +0 -23
  19. package/samples/integration-guide.md +0 -21
  20. package/samples/release-process.md +0 -23
  21. package/src/agents/assess.ts +0 -187
  22. package/src/agents/prompts.ts +0 -94
  23. package/src/agents/selectModel.ts +0 -50
  24. package/src/core/json.ts +0 -58
  25. package/src/core/prefilter.ts +0 -56
  26. package/src/core/slug.ts +0 -10
  27. package/src/core/verify.ts +0 -15
  28. package/src/extension.ts +0 -142
  29. package/src/findings/diagnostics.ts +0 -78
  30. package/src/findings/report.ts +0 -68
  31. package/src/findings/store.ts +0 -60
  32. package/src/findings/tree.ts +0 -93
  33. package/src/github/issues.ts +0 -90
  34. package/src/github/owners.ts +0 -60
  35. package/src/log.ts +0 -22
  36. package/src/scanner/candidates.ts +0 -62
  37. package/src/scanner/corpus.ts +0 -75
  38. package/src/scanner/scan.ts +0 -215
  39. package/test/candidates.test.ts +0 -63
  40. package/test/json.test.ts +0 -87
  41. package/test/prefilter.test.ts +0 -65
  42. package/test/slug.test.ts +0 -31
  43. package/test/verify.test.ts +0 -47
  44. package/tsconfig.json +0 -15
  45. package/vitest.config.mts +0 -9
  46. /package/{action/action.yml → action.yml} +0 -0
  47. /package/{action/bin → bin}/action.js +0 -0
  48. /package/{action/bin → bin}/docgrity.js +0 -0
  49. /package/{action/src → src}/corpus.js +0 -0
  50. /package/{action/src → src}/issues.js +0 -0
  51. /package/{action/src → src}/llm.js +0 -0
  52. /package/{action/src → src}/prompts.js +0 -0
  53. /package/{action/src → src}/report.js +0 -0
  54. /package/{action/src → src}/scan.js +0 -0
@@ -1,61 +0,0 @@
1
- # Example workflow: copy into your repo as .github/workflows/docgrity.yml
2
- name: Docgrity docs scan
3
-
4
- on:
5
- schedule:
6
- - cron: '0 6 * * 1' # weekly, Monday 06:00 UTC
7
- pull_request:
8
- paths: ['**/*.md']
9
- workflow_dispatch: {}
10
-
11
- permissions:
12
- contents: read
13
- issues: write # only needed when create_issues: true
14
- models: read # GitHub Models (default provider, free)
15
- pages: write # only needed for the Pages report job below
16
- id-token: write # only needed for the Pages report job below
17
-
18
- jobs:
19
- scan:
20
- runs-on: ubuntu-latest
21
- steps:
22
- - uses: actions/checkout@v4
23
- with:
24
- fetch-depth: 0 # full history so potential owners resolve via git log
25
-
26
- - name: Docgrity scan
27
- id: docgrity
28
- uses: ujjavala/docgrity-vscode/action@main
29
- env:
30
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
31
- with:
32
- provider: github-models # or gemini/openai/anthropic + api_key
33
- # api_key: ${{ secrets.DOCGRITY_API_KEY }}
34
- create_issues: ${{ github.event_name == 'schedule' }} # opt-in: only weekly runs raise issues
35
- max_new_issues: 5
36
-
37
- - name: Upload report artifact
38
- uses: actions/upload-artifact@v4
39
- with:
40
- name: docgrity-report
41
- path: docgrity-report/
42
-
43
- # Optional: publish the report to GitHub Pages (visibility follows repo access).
44
- publish-report:
45
- needs: scan
46
- if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
47
- runs-on: ubuntu-latest
48
- environment:
49
- name: github-pages
50
- url: ${{ steps.deployment.outputs.page_url }}
51
- steps:
52
- - uses: actions/download-artifact@v4
53
- with:
54
- name: docgrity-report
55
- path: site
56
- - uses: actions/configure-pages@v5
57
- - uses: actions/upload-pages-artifact@v3
58
- with:
59
- path: site
60
- - id: deployment
61
- uses: actions/deploy-pages@v4
@@ -1,38 +0,0 @@
1
- {
2
- "name": "docgrity",
3
- "version": "0.1.2",
4
- "description": "Docgrity doc-integrity scans for CI and local use: contradictions, duplicates and open questions across repository markdown docs. Read-only CLI — generates an HTML report; never posts anything.",
5
- "type": "module",
6
- "bin": { "docgrity": "bin/docgrity.js" },
7
- "files": [
8
- "bin/",
9
- "src/",
10
- "action.yml",
11
- "README.md",
12
- "LICENSE"
13
- ],
14
- "engines": { "node": ">=20" },
15
- "scripts": {
16
- "scan": "node bin/docgrity.js scan",
17
- "test": "node --test \"test/*.test.mjs\""
18
- },
19
- "repository": {
20
- "type": "git",
21
- "url": "git+https://github.com/ujjavala/docgrity-vscode.git",
22
- "directory": "action"
23
- },
24
- "homepage": "https://ujjavala.github.io/docgrity-vscode-site/",
25
- "bugs": "https://github.com/ujjavala/docgrity-vscode/issues",
26
- "keywords": [
27
- "documentation",
28
- "markdown",
29
- "lint",
30
- "contradiction",
31
- "duplicate",
32
- "llm",
33
- "cli",
34
- "github-action"
35
- ],
36
- "author": "ujjavala",
37
- "license": "MIT"
38
- }
@@ -1,59 +0,0 @@
1
- /** corpus.js tests — md-only collection, TF-IDF pairing, owner inference. */
2
- import { test } from 'node:test';
3
- import assert from 'node:assert/strict';
4
- import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
5
- import { tmpdir } from 'node:os';
6
- import path from 'node:path';
7
- import { collectCorpus, selectCandidatePairs, githubRepoSlug } from '../src/corpus.js';
8
-
9
- const doc = (relPath, text) => ({ relPath, text, hash: relPath });
10
-
11
- test('selectCandidatePairs pairs similar docs, ignores unrelated ones', () => {
12
- const a = doc('a.md', 'deployment guide for the payments service using canary rollout and grafana dashboards '.repeat(4));
13
- const b = doc('b.md', 'release process for the payments service with canary rollout watched in grafana '.repeat(4));
14
- const c = doc('c.md', 'chocolate cake recipe flour sugar eggs butter vanilla oven baking whisk frosting '.repeat(4));
15
- const pairs = selectCandidatePairs([a, b, c]);
16
- assert.ok(pairs.some((p) => p.a.relPath === 'a.md' && p.b.relPath === 'b.md'), 'similar docs must pair');
17
- assert.ok(!pairs.some((p) => p.a.relPath === 'c.md' || p.b.relPath === 'c.md'), 'unrelated doc must not pair');
18
- });
19
-
20
- test('selectCandidatePairs strips code blocks before comparing', () => {
21
- const code = '```\nconst deploy = canary(grafana, rollout, payments, service);\n```';
22
- const a = doc('a.md', `${code} completely unrelated prose about gardening tulips soil watering sunlight`.repeat(3));
23
- const b = doc('b.md', `${code} astronomy telescope galaxy nebula orbit planets observation stars`.repeat(3));
24
- const pairs = selectCandidatePairs([a, b]);
25
- assert.equal(pairs.length, 0, 'shared code blocks alone must not create a pair');
26
- });
27
-
28
- test('selectCandidatePairs respects maxPairs cap', () => {
29
- const docs = Array.from({ length: 6 }, (_, i) =>
30
- doc(`d${i}.md`, 'payments service deployment canary rollout grafana monitoring alerts '.repeat(4))
31
- );
32
- assert.ok(selectCandidatePairs(docs, 3).length <= 3);
33
- });
34
-
35
- test('collectCorpus: markdown only, skips ignored dirs and trivial files', async () => {
36
- const root = await mkdtemp(path.join(tmpdir(), 'docgrity-'));
37
- try {
38
- const big = 'This document is long enough to be included in the corpus. '.repeat(3);
39
- await writeFile(path.join(root, 'keep.md'), big);
40
- await writeFile(path.join(root, 'skip.txt'), big);
41
- await writeFile(path.join(root, 'tiny.md'), 'too short');
42
- await mkdir(path.join(root, 'node_modules'), { recursive: true });
43
- await writeFile(path.join(root, 'node_modules', 'dep.md'), big);
44
- const docs = await collectCorpus(root);
45
- assert.deepEqual(docs.map((d) => d.relPath), ['keep.md']);
46
- assert.match(docs[0].hash, /^[0-9a-f]{64}$/);
47
- } finally {
48
- await rm(root, { recursive: true, force: true });
49
- }
50
- });
51
-
52
- test('githubRepoSlug returns undefined outside a git repo', async () => {
53
- const root = await mkdtemp(path.join(tmpdir(), 'docgrity-'));
54
- try {
55
- assert.equal(await githubRepoSlug(root), undefined);
56
- } finally {
57
- await rm(root, { recursive: true, force: true });
58
- }
59
- });
@@ -1,89 +0,0 @@
1
- /** issues.js tests — dedupe by fingerprint, close resolved, cap creation.
2
- * GitHub API is mocked via global fetch; the LLM client is a stub. */
3
- import { test, beforeEach } from 'node:test';
4
- import assert from 'node:assert/strict';
5
- import { syncIssues } from '../src/issues.js';
6
-
7
- const SLUG = 'me/repo';
8
- const MARKER = (fp) => `<!-- docgrity:fingerprint:${fp} -->`;
9
-
10
- const stubClient = {
11
- model: 'stub',
12
- async complete() {
13
- return JSON.stringify({ title: 'Drafted title', body: 'Drafted body' });
14
- },
15
- };
16
-
17
- const finding = (fp, over = {}) => ({
18
- fingerprint: fp,
19
- type: 'duplicate',
20
- severity: 'MEDIUM',
21
- confidence: 0.8,
22
- summary: 'dup',
23
- evidence: [{ sourceLabel: 'a.md', excerpt: 'x' }],
24
- files: ['a.md'],
25
- potentialOwners: [],
26
- model: 'stub',
27
- promptVersion: 'v1',
28
- ...over,
29
- });
30
-
31
- let calls;
32
- let openIssues;
33
-
34
- beforeEach(() => {
35
- calls = [];
36
- openIssues = [];
37
- let nextNumber = 100;
38
- globalThis.fetch = async (url, init = {}) => {
39
- const method = init.method ?? 'GET';
40
- const u = new URL(url);
41
- calls.push({ method, path: u.pathname, body: init.body ? JSON.parse(init.body) : null });
42
- assert.match(init.headers.Authorization, /^Bearer /, 'token must be a bearer header');
43
- if (method === 'GET') return { ok: true, status: 200, json: async () => openIssues };
44
- if (method === 'POST' && u.pathname.endsWith('/issues')) {
45
- const n = nextNumber++;
46
- return { ok: true, status: 201, json: async () => ({ number: n, html_url: `https://github.com/${SLUG}/issues/${n}` }) };
47
- }
48
- return { ok: true, status: 200, json: async () => ({}) };
49
- };
50
- });
51
-
52
- test('creates issues for new findings with fingerprint marker and labels', async () => {
53
- const f = finding('aaaaaaaaaaaaaaaa');
54
- const result = await syncIssues({ client: stubClient, token: 't', slug: SLUG, findings: [f] });
55
- assert.equal(result.created.length, 1);
56
- const create = calls.find((c) => c.method === 'POST' && c.path === `/repos/${SLUG}/issues`);
57
- assert.ok(create.body.body.includes(MARKER('aaaaaaaaaaaaaaaa')), 'marker must be embedded');
58
- assert.deepEqual(create.body.labels, ['docgrity', 'docgrity:duplicate']);
59
- assert.ok(f.issueUrl, 'finding gets its issue url');
60
- });
61
-
62
- test('dedupes: existing fingerprint means no new issue', async () => {
63
- openIssues = [{ number: 7, html_url: 'u', body: `text ${MARKER('bbbbbbbbbbbbbbbb')}` }];
64
- const result = await syncIssues({
65
- client: stubClient,
66
- token: 't',
67
- slug: SLUG,
68
- findings: [finding('bbbbbbbbbbbbbbbb')],
69
- });
70
- assert.equal(result.created.length, 0);
71
- assert.equal(result.unchanged, 1);
72
- assert.ok(!calls.some((c) => c.method === 'POST' && c.path === `/repos/${SLUG}/issues`));
73
- });
74
-
75
- test('closes issues whose finding is resolved', async () => {
76
- openIssues = [{ number: 9, html_url: 'u', body: MARKER('cccccccccccccccc') }];
77
- const result = await syncIssues({ client: stubClient, token: 't', slug: SLUG, findings: [] });
78
- assert.deepEqual(result.closed, [9]);
79
- const patch = calls.find((c) => c.method === 'PATCH');
80
- assert.deepEqual(patch.body, { state: 'closed' });
81
- assert.ok(calls.some((c) => c.path.endsWith('/9/comments')), 'closing comment posted');
82
- });
83
-
84
- test('caps new issues per run', async () => {
85
- const findings = ['1111111111111111', '2222222222222222', '3333333333333333'].map(finding);
86
- const result = await syncIssues({ client: stubClient, token: 't', slug: SLUG, findings, maxNewIssues: 2 });
87
- assert.equal(result.created.length, 2);
88
- assert.equal(result.skipped, 1);
89
- });
@@ -1,76 +0,0 @@
1
- /** report.js tests — dashboard rendering, XSS escaping, empty state. */
2
- import { test } from 'node:test';
3
- import assert from 'node:assert/strict';
4
- import { renderReport, renderSummaryMarkdown } from '../src/report.js';
5
-
6
- const stats = { docs: 5, pairs: 9, scannedAt: '2026-09-05T00:00:00Z' };
7
-
8
- const finding = (over = {}) => ({
9
- fingerprint: 'abcd1234abcd1234',
10
- type: 'contradiction',
11
- severity: 'HIGH',
12
- confidence: 0.91,
13
- summary: 'Docs disagree on rate limits',
14
- detail: { conflicting_claims: ['100 rpm', '1000 rpm'] },
15
- evidence: [{ sourceLabel: 'a.md', excerpt: 'limited to 100 requests' }],
16
- files: ['a.md', 'b.md'],
17
- potentialOwners: ['alice'],
18
- model: 'gemini:test',
19
- promptVersion: 'v1',
20
- createdAt: '2026-09-05T00:00:00Z',
21
- ...over,
22
- });
23
-
24
- test('renderReport shows stat cards with correct counts', () => {
25
- const html = renderReport({
26
- findings: [finding(), finding({ type: 'duplicate', fingerprint: 'ffff0000ffff0000' })],
27
- stats,
28
- });
29
- assert.match(html, /Open findings/);
30
- assert.match(html, /<div class="num">2<\/div>/); // total
31
- assert.match(html, /Duplicates/);
32
- assert.match(html, /Contradictions/);
33
- assert.match(html, /Open questions/);
34
- assert.match(html, /class="ic/); // icons present
35
- });
36
-
37
- test('renderReport escapes HTML everywhere (XSS guard)', () => {
38
- const html = renderReport({
39
- findings: [
40
- finding({
41
- summary: '<script>alert(1)</script>',
42
- files: ['<img src=x onerror=1>.md'],
43
- evidence: [{ sourceLabel: '"><svg>', excerpt: '<iframe>' }],
44
- potentialOwners: ['<b>owner</b>'],
45
- detail: { conflicting_claims: ['<script>x</script>'] },
46
- }),
47
- ],
48
- stats,
49
- });
50
- assert.ok(!html.includes('<script>alert(1)</script>'));
51
- assert.ok(!html.includes('<img src=x'));
52
- assert.ok(!html.includes('<iframe>'));
53
- assert.ok(!html.includes('<b>owner</b>'));
54
- assert.ok(html.includes('&lt;script&gt;alert(1)&lt;/script&gt;'));
55
- });
56
-
57
- test('renderReport links files only when repoSlug present', () => {
58
- const withSlug = renderReport({ findings: [finding()], stats, repoSlug: 'me/repo', branch: 'main' });
59
- assert.match(withSlug, /https:\/\/github\.com\/me\/repo\/blob\/main\/a\.md/);
60
- const noSlug = renderReport({ findings: [finding()], stats });
61
- assert.ok(!noSlug.includes('github.com/me/repo'));
62
- });
63
-
64
- test('renderReport shows friendly empty state', () => {
65
- const html = renderReport({ findings: [], stats });
66
- assert.match(html, /No findings/);
67
- assert.match(html, /class="empty"/);
68
- });
69
-
70
- test('renderSummaryMarkdown escapes pipes and lists findings', () => {
71
- const md = renderSummaryMarkdown({ findings: [finding({ summary: 'a | b' })], stats });
72
- assert.match(md, /a \\\| b/);
73
- assert.match(md, /\| contradiction \| HIGH \| 91% \|/);
74
- const empty = renderSummaryMarkdown({ findings: [], stats });
75
- assert.match(empty, /No findings/);
76
- });
package/docgrity_logo.png DELETED
Binary file
package/image.png DELETED
Binary file
package/media/icon.png DELETED
Binary file
package/media/icon.svg DELETED
@@ -1,5 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
2
- <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
3
- <path d="M14 2v6h6"/>
4
- <path d="M9 15l2 2 4-4"/>
5
- </svg>
@@ -1,23 +0,0 @@
1
- # API Rate Limits
2
-
3
- Rate limiting policy for the public payments API.
4
-
5
- ## Limits
6
-
7
- Every API key is limited to **100 requests per minute**. Requests beyond the
8
- limit receive HTTP `429 Too Many Requests` with a `Retry-After` header.
9
-
10
- Burst traffic is not tolerated: the limiter uses a fixed one-minute window
11
- with no burst allowance.
12
-
13
- ## Authentication
14
-
15
- All API requests must authenticate with the `X-Api-Key` header. Bearer tokens
16
- are **not supported** on the public API — they are reserved for internal
17
- service-to-service calls.
18
-
19
- ## Webhooks
20
-
21
- Webhook deliveries are retried up to 3 times with exponential backoff. After
22
- the third failure the webhook endpoint is disabled and the account owner is
23
- emailed.
@@ -1,28 +0,0 @@
1
- # Architecture Notes — Payments Service
2
-
3
- Working notes from the March architecture review. Several decisions are still
4
- open and need owners.
5
-
6
- ## Data store
7
-
8
- We currently use Postgres for the ledger. TBD: do we shard by merchant ID or
9
- by region? The sharding decision blocks the multi-region rollout and nobody
10
- has been assigned to make the call.
11
-
12
- ## Event bus
13
-
14
- Kafka vs SQS is still an open question. Kafka gives us replay but SQS is what
15
- the platform team supports. Who decides this? Unclear — the platform team says
16
- it is a product decision, product says it is a platform decision.
17
-
18
- ## Idempotency
19
-
20
- TODO: we have not defined what the idempotency key format should be for the
21
- new refunds endpoint. The old endpoint used UUIDv4 but there was a proposal to
22
- switch to ULIDs — no conclusion was reached in the review.
23
-
24
- ## PCI scope
25
-
26
- It remains unresolved whether the new tokenisation proxy takes the webhook
27
- relay out of PCI scope. Awaiting a ruling from the compliance team (asked in
28
- January, no response yet).
@@ -1,23 +0,0 @@
1
- # Deployment Guide
2
-
3
- This document explains how to deploy the payments service to production.
4
-
5
- ## Prerequisites
6
-
7
- - Node.js 22 installed locally
8
- - Access to the `prod-deployers` GitHub team
9
- - The `PAYMENTS_DEPLOY_KEY` secret configured in your environment
10
-
11
- ## Steps
12
-
13
- 1. Create a release branch from `main` named `release/vX.Y.Z`.
14
- 2. Run the full test suite: `npm test -- --coverage`.
15
- 3. Bump the version in `package.json` and update `CHANGELOG.md`.
16
- 4. Tag the release: `git tag vX.Y.Z && git push --tags`.
17
- 5. Trigger the deploy workflow from the Actions tab, selecting the tag.
18
- 6. Watch the canary rollout in Grafana for 15 minutes before promoting to 100%.
19
-
20
- ## Rollback
21
-
22
- If error rates exceed 1% during canary, run the `rollback` workflow with the
23
- previous tag. Rollbacks complete in under 5 minutes.
@@ -1,21 +0,0 @@
1
- # Partner Integration Guide
2
-
3
- A quick-start guide for partners integrating with the public payments API.
4
-
5
- ## Getting started
6
-
7
- Request an API key from the developer portal. Every API key allows
8
- **1000 requests per minute**, and the rate limiter supports short bursts of
9
- up to 2000 requests thanks to a sliding-window algorithm with burst credit.
10
-
11
- ## Authentication
12
-
13
- Authenticate every request by sending your key as a Bearer token in the
14
- `Authorization` header: `Authorization: Bearer <api-key>`. This is the only
15
- supported authentication method for the public payments API.
16
-
17
- ## Webhooks
18
-
19
- Webhook deliveries are retried up to 10 times over 24 hours. Endpoints are
20
- never disabled automatically — failed deliveries simply expire after the
21
- final retry.
@@ -1,23 +0,0 @@
1
- # Release Process
2
-
3
- How to release the payments service to production.
4
-
5
- ## Before you start
6
-
7
- - Node.js 22 installed locally
8
- - Membership of the `prod-deployers` GitHub team
9
- - The `PAYMENTS_DEPLOY_KEY` secret configured in your environment
10
-
11
- ## Process
12
-
13
- 1. Create a release branch from `main` named `release/vX.Y.Z`.
14
- 2. Run the full test suite with coverage: `npm test -- --coverage`.
15
- 3. Bump the version in `package.json` and update `CHANGELOG.md`.
16
- 4. Tag the release: `git tag vX.Y.Z && git push --tags`.
17
- 5. Trigger the deploy workflow from the GitHub Actions tab, selecting the tag.
18
- 6. Watch the canary rollout in Grafana for 15 minutes before promoting to 100%.
19
-
20
- ## Rolling back
21
-
22
- If error rates exceed 1% during the canary phase, run the `rollback` workflow
23
- with the previous tag. A rollback completes in under 5 minutes.
@@ -1,187 +0,0 @@
1
- /**
2
- * LLM access — all requests go through vscode.lm (the user's own Copilot
3
- * subscription). Typed JSON only: every response is extracted and validated
4
- * in code; invalid output is rejected, never partially trusted.
5
- */
6
- import * as vscode from 'vscode';
7
- import { PROMPTS } from './prompts';
8
- import type { Doc } from '../scanner/corpus';
9
- import { log } from '../log';
10
- import {
11
- extractJson,
12
- num,
13
- str,
14
- validateEvidence,
15
- SEVERITIES,
16
- } from '../core/json';
17
-
18
- export type { Evidence } from '../core/json';
19
-
20
- export interface OpenQuestion {
21
- question: string;
22
- excerpt: string;
23
- confidence: number;
24
- severity: string;
25
- }
26
-
27
- async function pickModel(): Promise<vscode.LanguageModelChat> {
28
- // Models rotate; select at call time. Vendor/family are configurable so any
29
- // vscode.lm provider works — Copilot, or local models (e.g. Ollama/llama via
30
- // Copilot's "Manage models" BYOK, or a local-provider extension's vendor).
31
- const cfg = vscode.workspace.getConfiguration('docgrity');
32
- const vendor = cfg.get<string>('model.vendor', 'copilot');
33
- const family = cfg.get<string>('model.family', '');
34
-
35
- if (family) {
36
- const preferred = await vscode.lm.selectChatModels({ vendor, family });
37
- if (preferred.length > 0) return preferred[0];
38
- }
39
- const any = await vscode.lm.selectChatModels(vendor ? { vendor } : {});
40
- if (any.length === 0) {
41
- throw new Error(
42
- `No language model available for vendor "${vendor || 'any'}". Sign in to GitHub Copilot, ` +
43
- 'or set docgrity.model.vendor/family to a locally provided model (e.g. Ollama).'
44
- );
45
- }
46
- log.debug(`Model selected: ${any[0].id} (vendor=${vendor || 'any'}, no family match)`);
47
- return any[0];
48
- }
49
-
50
- export async function completeJson<T>(opts: {
51
- system: string;
52
- prompt: string;
53
- validate: (raw: any) => T;
54
- token: vscode.CancellationToken;
55
- }): Promise<{ output: T; model: string }> {
56
- const model = await pickModel();
57
- const messages = [
58
- vscode.LanguageModelChatMessage.Assistant(opts.system),
59
- vscode.LanguageModelChatMessage.User(opts.prompt),
60
- ];
61
- const started = Date.now();
62
- const response = await model.sendRequest(messages, {}, opts.token);
63
- let text = '';
64
- for await (const chunk of response.text) text += chunk;
65
- try {
66
- const output = opts.validate(extractJson(text));
67
- log.trace(`LLM response validated (model=${model.id}, ${Date.now() - started}ms)`);
68
- return { output, model: model.id };
69
- } catch (err) {
70
- log.warn(`LLM response rejected (model=${model.id}): ${(err as Error).message}`);
71
- throw err;
72
- }
73
- }
74
-
75
- function pairPrompt(a: Doc, b: Doc): string {
76
- return `PAGE A — "${a.relPath}":\n${a.text.slice(0, 8000)}\n\n---\n\nPAGE B — "${b.relPath}":\n${b.text.slice(0, 8000)}`;
77
- }
78
-
79
- function validateDuplicate(o: any) {
80
- return {
81
- is_duplicate: Boolean(o.is_duplicate),
82
- confidence: num(o.confidence),
83
- summary: str(o.summary).slice(0, 2000),
84
- recommended_action: str(o.recommended_action || 'REVIEW'),
85
- evidence: validateEvidence(o.evidence),
86
- };
87
- }
88
-
89
- function validateContradiction(o: any) {
90
- return {
91
- is_contradiction: Boolean(o.is_contradiction),
92
- confidence: num(o.confidence),
93
- severity: SEVERITIES.has(o.severity) ? (o.severity as string) : 'MEDIUM',
94
- summary: str(o.summary).slice(0, 2000),
95
- conflicting_claims: (Array.isArray(o.conflicting_claims) ? o.conflicting_claims : []).map(
96
- (c: unknown) => str(c).slice(0, 500)
97
- ),
98
- evidence: validateEvidence(o.evidence),
99
- };
100
- }
101
-
102
- /**
103
- * Combined duplicate + contradiction assessment in a single LLM call — the
104
- * model reads the same two documents once instead of twice, halving pair
105
- * latency and token cost with the same rules and output contracts.
106
- */
107
- export async function assessPair(a: Doc, b: Doc, token: vscode.CancellationToken) {
108
- const p = PROMPTS.pair;
109
- const { output, model } = await completeJson({
110
- system: p.system,
111
- prompt: pairPrompt(a, b),
112
- token,
113
- validate: (o) => ({
114
- duplicate: validateDuplicate(o.duplicate ?? {}),
115
- contradiction: validateContradiction(o.contradiction ?? {}),
116
- }),
117
- });
118
- return { output, model, promptVersion: p.version };
119
- }
120
-
121
- export async function assessDuplicate(a: Doc, b: Doc, token: vscode.CancellationToken) {
122
- const p = PROMPTS.duplicate;
123
- const { output, model } = await completeJson({
124
- system: p.system,
125
- prompt: pairPrompt(a, b),
126
- token,
127
- validate: validateDuplicate,
128
- });
129
- return { output, model, promptVersion: p.version };
130
- }
131
-
132
- export async function assessContradiction(a: Doc, b: Doc, token: vscode.CancellationToken) {
133
- const p = PROMPTS.contradiction;
134
- const { output, model } = await completeJson({
135
- system: p.system,
136
- prompt: pairPrompt(a, b),
137
- token,
138
- validate: validateContradiction,
139
- });
140
- return { output, model, promptVersion: p.version };
141
- }
142
-
143
- export async function assessOpenQuestions(doc: Doc, token: vscode.CancellationToken) {
144
- const p = PROMPTS.open_question;
145
- const { output, model } = await completeJson({
146
- system: p.system,
147
- prompt: `Document path: ${doc.relPath}\n\nDocument content:\n${doc.text.slice(0, 12000)}`,
148
- token,
149
- validate: (o) => ({
150
- questions: (Array.isArray(o.questions) ? o.questions : [])
151
- .map(
152
- (q: any): OpenQuestion => ({
153
- question: str(q.question).slice(0, 500),
154
- excerpt: str(q.excerpt).slice(0, 1000),
155
- confidence: num(q.confidence),
156
- severity: SEVERITIES.has(q.severity) ? (q.severity as string) : 'MEDIUM',
157
- })
158
- )
159
- .filter((q: OpenQuestion) => q.question && q.excerpt),
160
- }),
161
- });
162
- return { output, model, promptVersion: p.version };
163
- }
164
-
165
- export async function draftIssue(
166
- finding: {
167
- type: string;
168
- summary: string;
169
- evidence: { sourceLabel: string; excerpt: string }[];
170
- potentialOwners: string[];
171
- },
172
- token: vscode.CancellationToken
173
- ): Promise<{ title: string; body: string }> {
174
- const p = PROMPTS.issue;
175
- const { output } = await completeJson({
176
- system: p.system,
177
- prompt: `Finding type: ${finding.type}\nSummary: ${finding.summary}\nEvidence:\n${finding.evidence
178
- .map((e) => `- [${e.sourceLabel}] "${e.excerpt}"`)
179
- .join('\n')}\nPotential owners: ${finding.potentialOwners.join(', ') || 'unknown'}`,
180
- token,
181
- validate: (o) => ({
182
- title: str(o.title).slice(0, 200) || 'Docgrity finding',
183
- body: str(o.body).slice(0, 20000),
184
- }),
185
- });
186
- return output;
187
- }