@company-semantics/contracts 98.0.0 → 99.0.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@company-semantics/contracts",
3
- "version": "98.0.0",
3
+ "version": "99.0.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,3 +1,3 @@
1
1
  // AUTO-GENERATED — do not edit. Run pnpm generate:spec-hash to regenerate.
2
- export const SPEC_HASH = '51e9ee445972' as const;
3
- export const SPEC_HASH_FULL = '51e9ee445972ebf381cd85489343494ecdd81453574bbec8696a038ef4fd327f' as const;
2
+ export const SPEC_HASH = '935ee995d53d' as const;
3
+ export const SPEC_HASH_FULL = '935ee995d53d56538f0566bb3c1372041d5cb1def41f3b28db02716ad4d8c591' as const;
@@ -421,6 +421,23 @@ export interface paths {
421
421
  patch?: never;
422
422
  trace?: never;
423
423
  };
424
+ "/api/chats/{id}/branch": {
425
+ parameters: {
426
+ query?: never;
427
+ header?: never;
428
+ path?: never;
429
+ cookie?: never;
430
+ };
431
+ get?: never;
432
+ put?: never;
433
+ /** Make one version of a turn the active branch */
434
+ post: operations["selectChatBranch"];
435
+ delete?: never;
436
+ options?: never;
437
+ head?: never;
438
+ patch?: never;
439
+ trace?: never;
440
+ };
424
441
  "/api/chats/{id}/pin": {
425
442
  parameters: {
426
443
  query?: never;
@@ -5719,6 +5736,10 @@ export interface components {
5719
5736
  title: string;
5720
5737
  isAutoGenerated: boolean;
5721
5738
  };
5739
+ ChatBranchSelectRequest: {
5740
+ /** Format: uuid */
5741
+ messageId: string;
5742
+ };
5722
5743
  ChatSseEvent: {
5723
5744
  /** @constant */
5724
5745
  v: 1;
@@ -5987,14 +6008,16 @@ export interface components {
5987
6008
  ChatMessageFeedbackResponse: {
5988
6009
  /** @enum {string} */
5989
6010
  rating: "up" | "down";
5990
- reason: string | null;
6011
+ reason: ("incorrect" | "didnt_follow_instructions" | "not_helpful" | "missing_context" | "tool_or_workflow_issue" | "other") | null;
5991
6012
  /** Format: date-time */
5992
6013
  updatedAt: string;
5993
6014
  };
5994
6015
  PutChatMessageFeedbackRequest: {
5995
6016
  /** @enum {string} */
5996
6017
  rating: "up" | "down";
5997
- reason?: string;
6018
+ /** @enum {string} */
6019
+ reason?: "incorrect" | "didnt_follow_instructions" | "not_helpful" | "missing_context" | "tool_or_workflow_issue" | "other";
6020
+ comment?: string;
5998
6021
  };
5999
6022
  WorkspaceAccessResponse: {
6000
6023
  hasAccess: boolean;
@@ -12463,6 +12486,32 @@ export interface operations {
12463
12486
  };
12464
12487
  };
12465
12488
  };
12489
+ selectChatBranch: {
12490
+ parameters: {
12491
+ query?: never;
12492
+ header?: never;
12493
+ path: {
12494
+ id: string;
12495
+ };
12496
+ cookie?: never;
12497
+ };
12498
+ requestBody: {
12499
+ content: {
12500
+ "application/json": components["schemas"]["ChatBranchSelectRequest"];
12501
+ };
12502
+ };
12503
+ responses: {
12504
+ /** @description The chat with its new active branch */
12505
+ 200: {
12506
+ headers: {
12507
+ [name: string]: unknown;
12508
+ };
12509
+ content: {
12510
+ "application/json": components["schemas"]["GetChatResponse"];
12511
+ };
12512
+ };
12513
+ };
12514
+ };
12466
12515
  pinChat: {
12467
12516
  parameters: {
12468
12517
  query?: never;
@@ -81,13 +81,31 @@ function backendAgenticModelDefault(): string {
81
81
  );
82
82
  }
83
83
 
84
- /** The chat model the factory declares to the provider resolver. */
84
+ /**
85
+ * The chat model the factory declares to the provider resolver. The factory's
86
+ * CHAT_DEFAULT_MODEL reads `modelFor('chat.default')`, so the id lives on the
87
+ * `chat.default` entry of the backend's model use-site registry.
88
+ *
89
+ * Until backend PRD-01163 lands on its origin/main, the resolved backend (a
90
+ * detached checkout such as the PR gate reads origin/main) has no registry and
91
+ * still declares the literal in factory.ts. Read whichever declaration the
92
+ * resolved backend actually has — parity is asserted either way. Drop the
93
+ * factory.ts branch once the registry is on backend main.
94
+ */
85
95
  function backendChatModelDefault(): string {
96
+ if (!backend!.exists("src/runtimes/models/use-sites.ts")) {
97
+ return extractDeclaration(
98
+ readBackendSource("src", "chat", "orchestrator", "factory.ts"),
99
+ /CHAT_DEFAULT_MODEL\s*=\s*['"]([^'"]+)['"]/,
100
+ "CHAT_DEFAULT_MODEL",
101
+ "src/chat/orchestrator/factory.ts",
102
+ );
103
+ }
86
104
  return extractDeclaration(
87
- readBackendSource("src", "chat", "orchestrator", "factory.ts"),
88
- /CHAT_DEFAULT_MODEL\s*=\s*['"]([^'"]+)['"]/,
89
- "CHAT_DEFAULT_MODEL",
90
- "src/chat/orchestrator/factory.ts",
105
+ readBackendSource("src", "runtimes", "models", "use-sites.ts"),
106
+ /['"]chat\.default['"]\s*:\s*\{\s*modelId:\s*['"]([^'"]+)['"]/,
107
+ "the 'chat.default' use-site modelId",
108
+ "src/runtimes/models/use-sites.ts",
91
109
  );
92
110
  }
93
111
 
@@ -28,6 +28,7 @@ export const openApiRoutes = {
28
28
  '/api/chats/proactive/{eventId}': ['GET'],
29
29
  '/api/chats/resolve-launch': ['POST'],
30
30
  '/api/chats/{id}': ['DELETE', 'GET', 'PATCH'],
31
+ '/api/chats/{id}/branch': ['POST'],
31
32
  '/api/chats/{id}/generate-title': ['POST'],
32
33
  '/api/chats/{id}/messages': ['POST'],
33
34
  '/api/chats/{id}/messages/{messageId}': ['DELETE'],
@@ -1,49 +1,79 @@
1
1
  # ingestion/
2
2
 
3
- Shared vocabulary for the document-ingestion accept-set.
3
+ Shared vocabulary for the one ingestion pipeline: formats, surfaces, providers.
4
4
 
5
5
  ## Purpose
6
6
 
7
- The single source of truth for which file formats the platform accepts for
8
- upload — their MIME types, filename extensions, human label groups, and
9
- per-format byte caps. The backend normalizer joins these entries with its
10
- private dispatch table; the app upload UI derives its file-input `accept`
11
- attribute and supported-formats copy from the same data.
7
+ The single source of truth for which files the platform ingests and what each
8
+ surface does with them. The format registry names every accepted format — its
9
+ MIME type, filename extensions, family, class, human label group and byte cap.
10
+ The surface declarations say, per consuming surface (company-md, org-chart,
11
+ chat, profile-image), which treatment each admitted family gets and the byte
12
+ ceiling. The provider vocabulary names the native types an external source
13
+ (Google Drive) hands the pipeline and the registry format each exports to. The
14
+ backend normalizer, discovery and the app upload UI derive everything they need
15
+ from these three declarations.
12
16
 
13
17
  ## Invariants
14
18
 
15
19
  - `INGESTION_FORMATS` is the canonical list; `mime` is unique across entries.
16
- - Entries are ordered text → documents → images/diagrams → audio/video. The
20
+ Entries are ordered text → documents → images/diagrams → audio/video; the
17
21
  backend's MIME-order test and the derived UI strings depend on this order.
18
- - `INGEST_ACCEPT` and `SUPPORTED_LABEL` are **derived** from the registry, never
19
- hand-maintained — adding a format updates both automatically.
22
+ - Every format carries a `family` and a required `class` (`FormatClass`:
23
+ `docs | slides | sheets | pdf | text | image | diagram | audio | video`). No
24
+ consumer keeps its own extension → class table.
25
+ - A surface (`INGESTION_SURFACES`) declares `treatments` — family →
26
+ `text | vision | transcript | store` — and a required `maxBytes`.
27
+ `familiesOf`, `formatsFor`, `surfaceAdmits`, `treatmentFor`, `acceptFor`,
28
+ `supportedLabelFor` and `maxBytesFor` are **derived** from that declaration.
29
+ Treatments are per family, never per format.
30
+ - `vision` means the richest native form: images as images, a PDF as a binary
31
+ document, text-native formats as text.
32
+ - A provider (`INGESTION_PROVIDERS`) declares its native types, their export
33
+ targets and labels, and its container MIMEs. `providerFormatsFor`,
34
+ `classOfMime`, `inventoryClassesFor` and `labelOfMime` are **derived**.
35
+ - `acceptFor` emits extensions and MIMEs (".png,image/png,…"), so a
36
+ MIME-only matcher can use the same string as a file-input `accept`.
37
+ - Nothing outside this domain names a MIME, an extension or a provider-native
38
+ type; consumers ask these helpers (ADR slug `single-ingestion-pipeline`).
20
39
  - No normalizer-routing fields here (`canonicalKind`, `normalizerKey` are
21
- backend-only). This package stays pure vocabulary.
22
- - Every entry carries a `family`; `INGESTION_SURFACES` declares which families
23
- each upload surface admits, and `formatsFor` / `acceptFor` /
24
- `supportedLabelFor` / `maxBytesFor` are **derived** from that declaration
25
- (ADR-CONTRACTS-159). A surface never re-spells a MIME.
40
+ backend-only). This package stays pure vocabulary
41
+ (ADR slug `ingestion-surface-treatments-and-providers`).
26
42
 
27
43
  <!-- BEGIN GENERATED: readme-public-api — derived from code by `pnpm readme-api`. Do not edit. -->
28
44
 
29
45
  ## Public API
30
46
 
47
+ - `FORMAT_CLASSES` — The coarse, provider-agnostic kind of a format, used wherever the system classes a file without reading it…
48
+ - `FormatClass` _(type)_
31
49
  - `INGESTION_FORMATS` — The canonical public format registry.
32
- - `INGESTION_SURFACES` — The canonical surface declarations. - `company-md`: context-bank and goals extraction.
33
- - `INGEST_ACCEPT` — Comma-joined extension string for a file input's `accept` attribute.
50
+ - `INGESTION_PROVIDERS` — Every provider's native types and container MIMEs, keyed by provider.
51
+ - `INGESTION_SURFACES` — The canonical surface declarations. - `company-md`: context bank, goals extraction, Source Sync landing.
34
52
  - `IngestionFamily` _(type)_ — The semantic family a format belongs to.
35
53
  - `IngestionFormat` _(type)_ — One accepted upload format, keyed by its canonical MIME type.
36
- - `IngestionSurface` _(type)_ — What one upload surface consumes: the families it admits and, optionally, a byte ceiling tighter than the…
37
- - `IngestionSurfaceKey` _(type)_ — The upload surfaces that consume the registry.
38
- - `SUPPORTED_LABEL` — Human-readable supported-formats summary, derived from {@link humanLabels}.
54
+ - `IngestionProvider` _(type)_ — One provider: its native types and the structural container MIMEs it lists.
55
+ - `IngestionProviderKey` _(type)_ — External sources that hand the pipeline provider-native files.
56
+ - `IngestionSurface` _(type)_ — One surface: the treatment it applies per admitted family, and its byte ceiling.
57
+ - `IngestionSurfaceKey` _(type)_ — The surfaces that consume the registry.
58
+ - `InventoryClass` _(type)_ — A Source Sync inventory class: a format class, or a provider shortcut row.
59
+ - `ProviderFormat` _(type)_ — One MIME a provider can hand the pipeline, with the registry format it lands as.
60
+ - `ProviderNativeFormat` _(type)_ — A provider-native type that is exported to a registry format before ingestion.
61
+ - `TREATMENTS` — What a surface does with an admitted family.
62
+ - `Treatment` _(type)_
39
63
  - `acceptExtensions` — All accepted filename extensions, in registry order.
40
- - `acceptFor` — Comma-joined extension string for the surface's file input `accept`.
64
+ - `acceptFor` — The `<input accept>` value for a surface: per format, its extensions then its MIME…
41
65
  - `byMime` — Look up the entry for a canonical MIME; undefined means unsupported.
66
+ - `classOfMime` — The class of a provider-listed MIME; null for containers and unknown types.
67
+ - `familiesOf` — The families a surface admits, in canonical family order.
42
68
  - `formatsFor` — The registry formats a surface admits, in registry order.
43
69
  - `humanLabels` — Unique group labels for human display, in registry order.
44
- - `maxBytesFor` — The transport byte ceiling for a surface: the largest registry cap among its admitted formats, tightened to…
70
+ - `inventoryClassesFor` — The inventory classes discovery can record for a provider feeding a surface, in canonical class order, plus…
71
+ - `labelOfMime` — Display label for a MIME (provider-native first when a provider is given); null if unknown.
72
+ - `maxBytesFor` — The transport byte ceiling: the largest registry cap among admitted formats, tightened to the (required)…
73
+ - `providerFormatsFor` — Every MIME the provider can hand a surface (natives first, then registry order).
45
74
  - `supportedLabelFor` — "Supported: …" copy naming every label group the surface admits.
46
75
  - `surfaceAdmits` — True when the surface admits this registry format.
76
+ - `treatmentFor` — What the surface does with this format; null = the surface refuses it.
47
77
 
48
78
  <!-- END GENERATED: readme-public-api -->
49
79
 
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ INGESTION_PROVIDERS,
4
+ providerFormatsFor,
5
+ classOfMime,
6
+ inventoryClassesFor,
7
+ labelOfMime,
8
+ } from "../providers";
9
+ import { byMime } from "../registry";
10
+
11
+ describe("ingestion providers", () => {
12
+ it("every Drive native type exports to a registry format", () => {
13
+ for (const n of INGESTION_PROVIDERS["google-drive"].nativeFormats) {
14
+ expect(byMime(n.exportsTo)).toBeDefined();
15
+ }
16
+ });
17
+
18
+ it("the company-md Drive net is the three Workspace types plus the company-md formats", () => {
19
+ const net = providerFormatsFor("google-drive", "company-md");
20
+ expect(net.slice(0, 3).map((pf) => pf.mime)).toEqual([
21
+ "application/vnd.google-apps.document",
22
+ "application/vnd.google-apps.presentation",
23
+ "application/vnd.google-apps.spreadsheet",
24
+ ]);
25
+ expect(net.map((pf) => pf.mime)).toContain("application/pdf");
26
+ expect(net.map((pf) => pf.mime)).not.toContain("image/png");
27
+ expect(new Set(net.map((pf) => pf.mime)).size).toBe(net.length);
28
+ });
29
+
30
+ it("pins the company-md inventory classes the DB CHECK carries today", () => {
31
+ expect(inventoryClassesFor("google-drive", "company-md")).toEqual([
32
+ "docs",
33
+ "slides",
34
+ "sheets",
35
+ "pdf",
36
+ "text",
37
+ "shortcut",
38
+ ]);
39
+ });
40
+
41
+ it("classes native, downloaded and container MIMEs", () => {
42
+ expect(
43
+ classOfMime("google-drive", "application/vnd.google-apps.spreadsheet"),
44
+ ).toBe("sheets");
45
+ expect(classOfMime("google-drive", "text/csv")).toBe("sheets");
46
+ expect(classOfMime("google-drive", "image/png")).toBe("image");
47
+ expect(
48
+ classOfMime("google-drive", "application/vnd.google-apps.folder"),
49
+ ).toBeNull();
50
+ });
51
+
52
+ it("labels native and registry MIMEs", () => {
53
+ expect(
54
+ labelOfMime("application/vnd.google-apps.document", "google-drive"),
55
+ ).toBe("Google Doc");
56
+ expect(labelOfMime("application/pdf")).toBe("PDF");
57
+ expect(labelOfMime("application/x-unknown")).toBeNull();
58
+ });
59
+ });
@@ -1,12 +1,6 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import {
3
- INGESTION_FORMATS,
4
- byMime,
5
- acceptExtensions,
6
- humanLabels,
7
- INGEST_ACCEPT,
8
- SUPPORTED_LABEL,
9
- } from "../registry";
2
+ import { INGESTION_FORMATS, byMime, acceptExtensions } from "../registry";
3
+ import { FORMAT_CLASSES } from "../types";
10
4
 
11
5
  describe("ingestion registry", () => {
12
6
  it("has a unique MIME per entry", () => {
@@ -28,17 +22,23 @@ describe("ingestion registry", () => {
28
22
  expect(byMime("application/x-unknown")).toBeUndefined();
29
23
  });
30
24
 
31
- it("derives INGEST_ACCEPT from every extension in registry order", () => {
32
- expect(INGEST_ACCEPT).toBe(acceptExtensions().join(","));
33
- expect(INGEST_ACCEPT).toBe(
34
- ".txt,.md,.html,.htm,.csv,.rtf,.docx,.doc,.pptx,.ppt,.xlsx,.xls,.odt,.ods,.odp,.epub,.pdf,.png,.jpg,.jpeg,.webp,.gif,.heic,.svg,.drawio,.mp3,.m4a,.wav,.aac,.mp4,.m4v,.mov",
35
- );
25
+ it("every format carries a known class", () => {
26
+ for (const entry of INGESTION_FORMATS)
27
+ expect(FORMAT_CLASSES).toContain(entry.class);
36
28
  });
37
29
 
38
- it("derives SUPPORTED_LABEL from the unique group labels", () => {
39
- expect(SUPPORTED_LABEL).toBe(`Supported: ${humanLabels().join(", ")}`);
40
- expect(SUPPORTED_LABEL).toBe(
41
- "Supported: Text, Markdown, HTML, CSV, RTF, Word, PowerPoint, Excel, OpenDocument, EPUB, PDF, Image, Diagram, Audio, Video",
42
- );
30
+ it("classes documents the way discovery does", () => {
31
+ expect(byMime("text/csv")!.class).toBe("sheets");
32
+ expect(byMime("application/epub+zip")!.class).toBe("docs");
33
+ expect(byMime("application/vnd.ms-powerpoint")!.class).toBe("slides");
34
+ expect(byMime("application/pdf")!.class).toBe("pdf");
35
+ expect(byMime("text/markdown")!.class).toBe("text");
36
+ expect(byMime("image/svg+xml")!.class).toBe("diagram");
37
+ });
38
+
39
+ it("admits HEIF and no longer lists drawio", () => {
40
+ expect(byMime("image/heif")?.family).toBe("image");
41
+ expect(byMime("application/vnd.jgraph.mxfile")).toBeUndefined();
42
+ expect(acceptExtensions()).not.toContain(".drawio");
43
43
  });
44
44
  });
@@ -1,64 +1,92 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { INGESTION_FORMATS, byMime } from "../registry";
2
+ import { byMime } from "../registry";
3
+ import { TREATMENTS } from "../types";
4
+ import type { IngestionSurfaceKey } from "../types";
3
5
  import {
4
6
  INGESTION_SURFACES,
7
+ familiesOf,
8
+ treatmentFor,
5
9
  formatsFor,
6
- surfaceAdmits,
7
10
  acceptFor,
8
- supportedLabelFor,
9
11
  maxBytesFor,
10
12
  } from "../surfaces";
11
13
 
12
14
  describe("ingestion surfaces", () => {
13
- it("every registry entry carries a family", () => {
14
- for (const entry of INGESTION_FORMATS) {
15
- expect(entry.family).toMatch(
16
- /^(text|document|image|diagram|audio|video)$/,
17
- );
15
+ it("every surface declares treatments only for real families and a positive ceiling", () => {
16
+ for (const key of Object.keys(
17
+ INGESTION_SURFACES,
18
+ ) as IngestionSurfaceKey[]) {
19
+ const s = INGESTION_SURFACES[key];
20
+ expect(s.key).toBe(key);
21
+ expect(s.maxBytes).toBeGreaterThan(0);
22
+ for (const [family, treatment] of Object.entries(s.treatments)) {
23
+ expect([
24
+ "text",
25
+ "document",
26
+ "image",
27
+ "diagram",
28
+ "audio",
29
+ "video",
30
+ ]).toContain(family);
31
+ expect(TREATMENTS).toContain(treatment);
32
+ }
33
+ expect(familiesOf(key).length).toBeGreaterThan(0);
18
34
  }
19
35
  });
20
36
 
21
- it("every surface admits at least one format, in registry order", () => {
22
- for (const key of Object.keys(INGESTION_SURFACES) as Array<
23
- keyof typeof INGESTION_SURFACES
24
- >) {
25
- const formats = formatsFor(key);
26
- expect(formats.length).toBeGreaterThan(0);
27
- const order = formats.map((f) => INGESTION_FORMATS.indexOf(f));
28
- expect(order).toEqual([...order].sort((a, b) => a - b));
29
- }
37
+ it("company-md turns text and documents into text and refuses images and media", () => {
38
+ expect(treatmentFor("company-md", byMime("application/pdf")!)).toBe("text");
39
+ expect(treatmentFor("company-md", byMime("text/csv")!)).toBe("text");
40
+ expect(treatmentFor("company-md", byMime("image/png")!)).toBeNull();
41
+ expect(treatmentFor("company-md", byMime("audio/mpeg")!)).toBeNull();
42
+ expect(maxBytesFor("company-md")).toBe(50_000_000);
30
43
  });
31
44
 
32
- it("company-md admits text and documents only", () => {
33
- expect(surfaceAdmits("company-md", byMime("application/pdf")!)).toBe(true);
34
- expect(surfaceAdmits("company-md", byMime("text/csv")!)).toBe(true);
35
- expect(surfaceAdmits("company-md", byMime("image/png")!)).toBe(false);
36
- expect(surfaceAdmits("company-md", byMime("audio/mpeg")!)).toBe(false);
37
- expect(acceptFor("company-md")).toBe(
38
- ".txt,.md,.html,.htm,.csv,.rtf,.docx,.doc,.pptx,.ppt,.xlsx,.xls,.odt,.ods,.odp,.epub,.pdf",
39
- );
40
- expect(supportedLabelFor("company-md")).toBe(
41
- "Supported: Text, Markdown, HTML, CSV, RTF, Word, PowerPoint, Excel, OpenDocument, EPUB, PDF",
45
+ it("org-chart hands documents, images and diagrams to vision", () => {
46
+ expect(treatmentFor("org-chart", byMime("application/pdf")!)).toBe(
47
+ "vision",
42
48
  );
49
+ expect(treatmentFor("org-chart", byMime("image/heic")!)).toBe("vision");
50
+ expect(treatmentFor("org-chart", byMime("image/svg+xml")!)).toBe("vision");
51
+ expect(treatmentFor("org-chart", byMime("video/mp4")!)).toBeNull();
52
+ expect(maxBytesFor("org-chart")).toBe(10 * 1024 * 1024);
53
+ });
54
+
55
+ it("chat admits text, documents and images but never media", () => {
56
+ expect(treatmentFor("chat", byMime("text/markdown")!)).toBe("text");
57
+ expect(treatmentFor("chat", byMime("application/pdf")!)).toBe("vision");
58
+ expect(treatmentFor("chat", byMime("image/png")!)).toBe("vision");
59
+ expect(treatmentFor("chat", byMime("audio/mpeg")!)).toBeNull();
60
+ expect(maxBytesFor("chat")).toBe(5 * 1024 * 1024);
61
+ });
62
+
63
+ it("chat caps files per turn and only chat declares a per-turn count", () => {
64
+ expect(INGESTION_SURFACES.chat.maxFilesPerTurn).toBe(8);
65
+ for (const key of ["company-md", "org-chart", "profile-image"] as const) {
66
+ expect(INGESTION_SURFACES[key].maxFilesPerTurn).toBeUndefined();
67
+ }
43
68
  });
44
69
 
45
- it("org-chart adds images and diagrams but never media", () => {
46
- expect(surfaceAdmits("org-chart", byMime("image/heic")!)).toBe(true);
47
- expect(surfaceAdmits("org-chart", byMime("image/svg+xml")!)).toBe(true);
48
- expect(surfaceAdmits("org-chart", byMime("video/mp4")!)).toBe(false);
49
- expect(acceptFor("org-chart")).toBe(
50
- ".txt,.md,.html,.htm,.csv,.rtf,.docx,.doc,.pptx,.ppt,.xlsx,.xls,.odt,.ods,.odp,.epub,.pdf,.png,.jpg,.jpeg,.webp,.gif,.heic,.svg,.drawio",
70
+ it("profile-image stores images only", () => {
71
+ expect(familiesOf("profile-image")).toEqual(["image"]);
72
+ expect(treatmentFor("profile-image", byMime("image/heif")!)).toBe("store");
73
+ expect(acceptFor("profile-image")).toBe(
74
+ ".png,image/png,.jpg,.jpeg,image/jpeg,.webp,image/webp,.gif,image/gif,.heic,image/heic,.heif,image/heif",
51
75
  );
76
+ expect(maxBytesFor("profile-image")).toBe(5 * 1024 * 1024);
52
77
  });
53
78
 
54
- it("derives the byte ceiling from the admitted formats and the surface cap", () => {
55
- expect(maxBytesFor("company-md")).toBe(50_000_000);
56
- expect(maxBytesFor("org-chart")).toBe(10 * 1024 * 1024);
57
- for (const key of ["company-md", "org-chart"] as const) {
58
- const ceiling = INGESTION_SURFACES[key].maxBytes ?? Infinity;
59
- expect(maxBytesFor(key)).toBeLessThanOrEqual(ceiling);
79
+ it("acceptFor lists every admitted extension and MIME, in registry order", () => {
80
+ for (const key of Object.keys(
81
+ INGESTION_SURFACES,
82
+ ) as IngestionSurfaceKey[]) {
83
+ const tokens = acceptFor(key).split(",");
84
+ for (const f of formatsFor(key)) {
85
+ expect(tokens).toContain(f.mime);
86
+ for (const ext of f.extensions) expect(tokens).toContain(ext);
87
+ }
60
88
  expect(maxBytesFor(key)).toBeLessThanOrEqual(
61
- Math.max(...formatsFor(key).map((f) => f.maxBytes)),
89
+ INGESTION_SURFACES[key].maxBytes,
62
90
  );
63
91
  }
64
92
  });
@@ -13,22 +13,40 @@ export type {
13
13
  IngestionFormat,
14
14
  IngestionSurface,
15
15
  IngestionSurfaceKey,
16
+ FormatClass,
17
+ InventoryClass,
18
+ Treatment,
19
+ IngestionProvider,
20
+ IngestionProviderKey,
21
+ ProviderNativeFormat,
16
22
  } from "./types";
17
23
 
24
+ export { FORMAT_CLASSES, TREATMENTS } from "./types";
25
+
18
26
  export {
19
27
  INGESTION_FORMATS,
20
28
  byMime,
21
29
  acceptExtensions,
22
30
  humanLabels,
23
- INGEST_ACCEPT,
24
- SUPPORTED_LABEL,
25
31
  } from "./registry";
26
32
 
27
33
  export {
28
34
  INGESTION_SURFACES,
35
+ familiesOf,
36
+ treatmentFor,
29
37
  formatsFor,
30
38
  surfaceAdmits,
31
39
  acceptFor,
32
40
  supportedLabelFor,
33
41
  maxBytesFor,
34
42
  } from "./surfaces";
43
+
44
+ export type { ProviderFormat } from "./providers";
45
+
46
+ export {
47
+ INGESTION_PROVIDERS,
48
+ providerFormatsFor,
49
+ classOfMime,
50
+ inventoryClassesFor,
51
+ labelOfMime,
52
+ } from "./providers";
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Ingestion Providers
3
+ *
4
+ * Provider vocabulary: the native types an external source hands us (exported
5
+ * to a registry format before ingestion) and its structural container MIMEs.
6
+ * Discovery's net, the Drive picker filter, inventory classes and display
7
+ * labels all derive from this plus the surface declarations — no backend or
8
+ * app module keeps its own Workspace MIME table.
9
+ */
10
+ import { INGESTION_FORMATS, byMime } from "./registry";
11
+ import { surfaceAdmits } from "./surfaces";
12
+ import {
13
+ FORMAT_CLASSES,
14
+ type FormatClass,
15
+ type IngestionFormat,
16
+ type IngestionProvider,
17
+ type IngestionProviderKey,
18
+ type IngestionSurfaceKey,
19
+ type InventoryClass,
20
+ type ProviderNativeFormat,
21
+ } from "./types";
22
+
23
+ /** Every provider's native types and container MIMEs, keyed by provider. */
24
+ export const INGESTION_PROVIDERS: Readonly<
25
+ Record<IngestionProviderKey, IngestionProvider>
26
+ > = {
27
+ "google-drive": {
28
+ key: "google-drive",
29
+ nativeFormats: [
30
+ {
31
+ mime: "application/vnd.google-apps.document",
32
+ label: "Google Doc",
33
+ exportsTo:
34
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
35
+ },
36
+ {
37
+ mime: "application/vnd.google-apps.presentation",
38
+ label: "Google Slides",
39
+ exportsTo:
40
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
41
+ },
42
+ {
43
+ mime: "application/vnd.google-apps.spreadsheet",
44
+ label: "Google Sheet",
45
+ exportsTo:
46
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
47
+ },
48
+ ],
49
+ containerMimes: {
50
+ folder: "application/vnd.google-apps.folder",
51
+ shortcut: "application/vnd.google-apps.shortcut",
52
+ },
53
+ },
54
+ };
55
+
56
+ /** One MIME a provider can hand the pipeline, with the registry format it lands as. */
57
+ export interface ProviderFormat {
58
+ /** The MIME as the provider lists it (native or a registry MIME). */
59
+ mime: string;
60
+ /** True for a provider-native type that is exported before ingestion. */
61
+ native: boolean;
62
+ /** The registry format the bytes arrive as (the export target for natives). */
63
+ format: IngestionFormat;
64
+ /** Display label: the native label, else the registry label. */
65
+ label: string;
66
+ }
67
+
68
+ function exportTarget(native: ProviderNativeFormat): IngestionFormat {
69
+ const target = byMime(native.exportsTo);
70
+ if (!target) {
71
+ throw new Error(
72
+ `ingestion provider: ${native.mime} exports to ${native.exportsTo}, which is not in INGESTION_FORMATS`,
73
+ );
74
+ }
75
+ return target;
76
+ }
77
+
78
+ /** Every MIME the provider can hand a surface (natives first, then registry order). */
79
+ export function providerFormatsFor(
80
+ provider: IngestionProviderKey,
81
+ surface: IngestionSurfaceKey,
82
+ ): ProviderFormat[] {
83
+ const natives: ProviderFormat[] = INGESTION_PROVIDERS[
84
+ provider
85
+ ].nativeFormats.map((native) => ({
86
+ mime: native.mime,
87
+ native: true,
88
+ format: exportTarget(native),
89
+ label: native.label,
90
+ }));
91
+ const downloads: ProviderFormat[] = INGESTION_FORMATS.map((format) => ({
92
+ mime: format.mime,
93
+ native: false,
94
+ format,
95
+ label: format.label,
96
+ }));
97
+ return [...natives, ...downloads].filter((pf) =>
98
+ surfaceAdmits(surface, pf.format),
99
+ );
100
+ }
101
+
102
+ /** The class of a provider-listed MIME; null for containers and unknown types. */
103
+ export function classOfMime(
104
+ provider: IngestionProviderKey,
105
+ mime: string,
106
+ ): FormatClass | null {
107
+ const native = INGESTION_PROVIDERS[provider].nativeFormats.find(
108
+ (n) => n.mime === mime,
109
+ );
110
+ if (native) return exportTarget(native).class;
111
+ return byMime(mime)?.class ?? null;
112
+ }
113
+
114
+ /**
115
+ * The inventory classes discovery can record for a provider feeding a surface,
116
+ * in canonical class order, plus "shortcut". The DB CHECK on
117
+ * source_inventory_items.mime_class is pinned to this.
118
+ */
119
+ export function inventoryClassesFor(
120
+ provider: IngestionProviderKey,
121
+ surface: IngestionSurfaceKey,
122
+ ): InventoryClass[] {
123
+ const present = new Set(
124
+ providerFormatsFor(provider, surface).map((pf) => pf.format.class),
125
+ );
126
+ return [...FORMAT_CLASSES.filter((c) => present.has(c)), "shortcut"];
127
+ }
128
+
129
+ /** Display label for a MIME (provider-native first when a provider is given); null if unknown. */
130
+ export function labelOfMime(
131
+ mime: string,
132
+ provider?: IngestionProviderKey,
133
+ ): string | null {
134
+ if (provider) {
135
+ const native = INGESTION_PROVIDERS[provider].nativeFormats.find(
136
+ (n) => n.mime === mime,
137
+ );
138
+ if (native) return native.label;
139
+ }
140
+ return byMime(mime)?.label ?? null;
141
+ }
@@ -2,15 +2,18 @@
2
2
  * Ingestion Format Registry
3
3
  *
4
4
  * THE single source of truth for the public document-ingestion accept-set. The
5
- * backend normalizer joins these entries with its private dispatch table; the
6
- * app derives its file-input `accept` attribute and supported-formats copy from
7
- * the helpers below. Neither side re-spells the extension or label list.
5
+ * backend normalizer joins these entries with its private dispatch table; each
6
+ * upload surface derives its file-input `accept` attribute and supported-formats
7
+ * copy from the surface helpers. Neither side re-spells the extension, label or
8
+ * class list.
8
9
  *
9
10
  * Invariants:
10
11
  * - Entries are ordered text → documents → images/diagrams → audio/video; the
11
12
  * backend's MIME-order test and the derived UI strings depend on this order.
12
- * - `INGEST_ACCEPT` and `SUPPORTED_LABEL` are DERIVED, never hand-maintained —
13
- * adding a format here updates both automatically.
13
+ * - Every entry declares its `class` (one of `FORMAT_CLASSES`); no consumer
14
+ * keeps its own extension → class table.
15
+ * - `.drawio` is not listed: no normalizer can rasterize a raw `<mxfile>`, so
16
+ * admitting it would be a lie. It returns when a rasterizer exists.
14
17
  *
15
18
  * @see ADR-CONT-075 for design rationale
16
19
  */
@@ -22,6 +25,7 @@ export const INGESTION_FORMATS: readonly IngestionFormat[] = [
22
25
  {
23
26
  mime: "text/plain",
24
27
  family: "text",
28
+ class: "text",
25
29
  extensions: [".txt"],
26
30
  label: "Text",
27
31
  maxBytes: 10_000_000,
@@ -29,6 +33,7 @@ export const INGESTION_FORMATS: readonly IngestionFormat[] = [
29
33
  {
30
34
  mime: "text/markdown",
31
35
  family: "text",
36
+ class: "text",
32
37
  extensions: [".md"],
33
38
  label: "Markdown",
34
39
  maxBytes: 10_000_000,
@@ -36,6 +41,7 @@ export const INGESTION_FORMATS: readonly IngestionFormat[] = [
36
41
  {
37
42
  mime: "text/html",
38
43
  family: "text",
44
+ class: "text",
39
45
  extensions: [".html", ".htm"],
40
46
  label: "HTML",
41
47
  maxBytes: 10_000_000,
@@ -43,6 +49,7 @@ export const INGESTION_FORMATS: readonly IngestionFormat[] = [
43
49
  {
44
50
  mime: "text/csv",
45
51
  family: "text",
52
+ class: "sheets",
46
53
  extensions: [".csv"],
47
54
  label: "CSV",
48
55
  maxBytes: 25_000_000,
@@ -50,6 +57,7 @@ export const INGESTION_FORMATS: readonly IngestionFormat[] = [
50
57
  {
51
58
  mime: "application/rtf",
52
59
  family: "document",
60
+ class: "docs",
53
61
  extensions: [".rtf"],
54
62
  label: "RTF",
55
63
  maxBytes: 25_000_000,
@@ -57,6 +65,7 @@ export const INGESTION_FORMATS: readonly IngestionFormat[] = [
57
65
  {
58
66
  mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
59
67
  family: "document",
68
+ class: "docs",
60
69
  extensions: [".docx"],
61
70
  label: "Word",
62
71
  maxBytes: 25_000_000,
@@ -64,6 +73,7 @@ export const INGESTION_FORMATS: readonly IngestionFormat[] = [
64
73
  {
65
74
  mime: "application/msword",
66
75
  family: "document",
76
+ class: "docs",
67
77
  extensions: [".doc"],
68
78
  label: "Word",
69
79
  maxBytes: 25_000_000,
@@ -71,6 +81,7 @@ export const INGESTION_FORMATS: readonly IngestionFormat[] = [
71
81
  {
72
82
  mime: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
73
83
  family: "document",
84
+ class: "slides",
74
85
  extensions: [".pptx"],
75
86
  label: "PowerPoint",
76
87
  maxBytes: 50_000_000,
@@ -78,6 +89,7 @@ export const INGESTION_FORMATS: readonly IngestionFormat[] = [
78
89
  {
79
90
  mime: "application/vnd.ms-powerpoint",
80
91
  family: "document",
92
+ class: "slides",
81
93
  extensions: [".ppt"],
82
94
  label: "PowerPoint",
83
95
  maxBytes: 50_000_000,
@@ -85,6 +97,7 @@ export const INGESTION_FORMATS: readonly IngestionFormat[] = [
85
97
  {
86
98
  mime: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
87
99
  family: "document",
100
+ class: "sheets",
88
101
  extensions: [".xlsx"],
89
102
  label: "Excel",
90
103
  maxBytes: 50_000_000,
@@ -92,6 +105,7 @@ export const INGESTION_FORMATS: readonly IngestionFormat[] = [
92
105
  {
93
106
  mime: "application/vnd.ms-excel",
94
107
  family: "document",
108
+ class: "sheets",
95
109
  extensions: [".xls"],
96
110
  label: "Excel",
97
111
  maxBytes: 50_000_000,
@@ -99,6 +113,7 @@ export const INGESTION_FORMATS: readonly IngestionFormat[] = [
99
113
  {
100
114
  mime: "application/vnd.oasis.opendocument.text",
101
115
  family: "document",
116
+ class: "docs",
102
117
  extensions: [".odt"],
103
118
  label: "OpenDocument",
104
119
  maxBytes: 25_000_000,
@@ -106,6 +121,7 @@ export const INGESTION_FORMATS: readonly IngestionFormat[] = [
106
121
  {
107
122
  mime: "application/vnd.oasis.opendocument.spreadsheet",
108
123
  family: "document",
124
+ class: "sheets",
109
125
  extensions: [".ods"],
110
126
  label: "OpenDocument",
111
127
  maxBytes: 50_000_000,
@@ -113,6 +129,7 @@ export const INGESTION_FORMATS: readonly IngestionFormat[] = [
113
129
  {
114
130
  mime: "application/vnd.oasis.opendocument.presentation",
115
131
  family: "document",
132
+ class: "slides",
116
133
  extensions: [".odp"],
117
134
  label: "OpenDocument",
118
135
  maxBytes: 50_000_000,
@@ -120,6 +137,7 @@ export const INGESTION_FORMATS: readonly IngestionFormat[] = [
120
137
  {
121
138
  mime: "application/epub+zip",
122
139
  family: "document",
140
+ class: "docs",
123
141
  extensions: [".epub"],
124
142
  label: "EPUB",
125
143
  maxBytes: 25_000_000,
@@ -127,6 +145,7 @@ export const INGESTION_FORMATS: readonly IngestionFormat[] = [
127
145
  {
128
146
  mime: "application/pdf",
129
147
  family: "document",
148
+ class: "pdf",
130
149
  extensions: [".pdf"],
131
150
  label: "PDF",
132
151
  maxBytes: 50_000_000,
@@ -134,6 +153,7 @@ export const INGESTION_FORMATS: readonly IngestionFormat[] = [
134
153
  {
135
154
  mime: "image/png",
136
155
  family: "image",
156
+ class: "image",
137
157
  extensions: [".png"],
138
158
  label: "Image",
139
159
  maxBytes: 25_000_000,
@@ -141,6 +161,7 @@ export const INGESTION_FORMATS: readonly IngestionFormat[] = [
141
161
  {
142
162
  mime: "image/jpeg",
143
163
  family: "image",
164
+ class: "image",
144
165
  extensions: [".jpg", ".jpeg"],
145
166
  label: "Image",
146
167
  maxBytes: 25_000_000,
@@ -148,6 +169,7 @@ export const INGESTION_FORMATS: readonly IngestionFormat[] = [
148
169
  {
149
170
  mime: "image/webp",
150
171
  family: "image",
172
+ class: "image",
151
173
  extensions: [".webp"],
152
174
  label: "Image",
153
175
  maxBytes: 25_000_000,
@@ -155,6 +177,7 @@ export const INGESTION_FORMATS: readonly IngestionFormat[] = [
155
177
  {
156
178
  mime: "image/gif",
157
179
  family: "image",
180
+ class: "image",
158
181
  extensions: [".gif"],
159
182
  label: "Image",
160
183
  maxBytes: 25_000_000,
@@ -162,27 +185,31 @@ export const INGESTION_FORMATS: readonly IngestionFormat[] = [
162
185
  {
163
186
  mime: "image/heic",
164
187
  family: "image",
188
+ class: "image",
165
189
  extensions: [".heic"],
166
190
  label: "Image",
167
191
  maxBytes: 25_000_000,
168
192
  },
169
193
  {
170
- mime: "image/svg+xml",
171
- family: "diagram",
172
- extensions: [".svg"],
173
- label: "Diagram",
174
- maxBytes: 10_000_000,
194
+ mime: "image/heif",
195
+ family: "image",
196
+ class: "image",
197
+ extensions: [".heif"],
198
+ label: "Image",
199
+ maxBytes: 25_000_000,
175
200
  },
176
201
  {
177
- mime: "application/vnd.jgraph.mxfile",
202
+ mime: "image/svg+xml",
178
203
  family: "diagram",
179
- extensions: [".drawio"],
204
+ class: "diagram",
205
+ extensions: [".svg"],
180
206
  label: "Diagram",
181
207
  maxBytes: 10_000_000,
182
208
  },
183
209
  {
184
210
  mime: "audio/mpeg",
185
211
  family: "audio",
212
+ class: "audio",
186
213
  extensions: [".mp3"],
187
214
  label: "Audio",
188
215
  maxBytes: 500_000_000,
@@ -190,6 +217,7 @@ export const INGESTION_FORMATS: readonly IngestionFormat[] = [
190
217
  {
191
218
  mime: "audio/x-m4a",
192
219
  family: "audio",
220
+ class: "audio",
193
221
  extensions: [".m4a"],
194
222
  label: "Audio",
195
223
  maxBytes: 500_000_000,
@@ -197,6 +225,7 @@ export const INGESTION_FORMATS: readonly IngestionFormat[] = [
197
225
  {
198
226
  mime: "audio/wav",
199
227
  family: "audio",
228
+ class: "audio",
200
229
  extensions: [".wav"],
201
230
  label: "Audio",
202
231
  maxBytes: 500_000_000,
@@ -204,6 +233,7 @@ export const INGESTION_FORMATS: readonly IngestionFormat[] = [
204
233
  {
205
234
  mime: "audio/aac",
206
235
  family: "audio",
236
+ class: "audio",
207
237
  extensions: [".aac"],
208
238
  label: "Audio",
209
239
  maxBytes: 500_000_000,
@@ -211,6 +241,7 @@ export const INGESTION_FORMATS: readonly IngestionFormat[] = [
211
241
  {
212
242
  mime: "video/mp4",
213
243
  family: "video",
244
+ class: "video",
214
245
  extensions: [".mp4", ".m4v"],
215
246
  label: "Video",
216
247
  maxBytes: 500_000_000,
@@ -218,6 +249,7 @@ export const INGESTION_FORMATS: readonly IngestionFormat[] = [
218
249
  {
219
250
  mime: "video/quicktime",
220
251
  family: "video",
252
+ class: "video",
221
253
  extensions: [".mov"],
222
254
  label: "Video",
223
255
  maxBytes: 500_000_000,
@@ -238,16 +270,3 @@ export function acceptExtensions(): string[] {
238
270
  export function humanLabels(): string[] {
239
271
  return [...new Set(INGESTION_FORMATS.map((entry) => entry.label))];
240
272
  }
241
-
242
- /**
243
- * Comma-joined extension string for a file input's `accept` attribute.
244
- * Derived from {@link acceptExtensions} — never hand-maintained.
245
- */
246
- export const INGEST_ACCEPT: string = acceptExtensions().join(",");
247
-
248
- /**
249
- * Human-readable supported-formats summary, derived from {@link humanLabels}.
250
- * Names every accepted group (in registry order) so the copy can't undersell
251
- * what actually works.
252
- */
253
- export const SUPPORTED_LABEL: string = `Supported: ${humanLabels().join(", ")}`;
@@ -1,58 +1,104 @@
1
1
  /**
2
2
  * Ingestion Surfaces
3
3
  *
4
- * Per-surface views of the single format registry. A surface names the
5
- * families it consumes; everything else — the formats, the file-input `accept`
6
- * string, the "Supported: …" copy, the transport byte ceiling — is DERIVED from
4
+ * Per-surface views of the single format registry. A surface declares a
5
+ * treatment per family it consumes plus a required byte ceiling; everything
6
+ * else — the families, the formats, the file-input `accept` string, the
7
+ * "Supported: …" copy, the transport byte ceiling — is DERIVED from
7
8
  * `INGESTION_FORMATS` through that declaration. No surface re-spells a MIME.
8
9
  *
9
10
  * Invariants:
10
11
  * - Every surface admits at least one registry format.
11
12
  * - `maxBytesFor` never exceeds the registry cap of any admitted format, and
12
- * never exceeds the surface ceiling when one is declared.
13
+ * never exceeds the surface ceiling.
13
14
  *
14
15
  * @see ADR-CONTRACTS-159 for design rationale
15
16
  */
16
17
 
17
18
  import { INGESTION_FORMATS } from "./registry";
18
19
  import type {
20
+ IngestionFamily,
19
21
  IngestionFormat,
20
22
  IngestionSurface,
21
23
  IngestionSurfaceKey,
24
+ Treatment,
22
25
  } from "./types";
23
26
 
24
- /** 10 MiB — the org-chart ceiling (see the surface JSDoc below). */
25
- const ORG_CHART_MAX_BYTES = 10 * 1024 * 1024;
27
+ const MIB = 1024 * 1024;
28
+
29
+ /** Canonical family order; `familiesOf` returns families in this order. */
30
+ const FAMILY_ORDER: readonly IngestionFamily[] = [
31
+ "text",
32
+ "document",
33
+ "image",
34
+ "diagram",
35
+ "audio",
36
+ "video",
37
+ ];
26
38
 
27
39
  /**
28
40
  * The canonical surface declarations.
29
41
  *
30
- * - `company-md`: context-bank and goals extraction. Consumes text and
31
- * documents; images, diagrams and media are not extracted on this surface
32
- * yet, so the picker must not advertise them.
33
- * - `org-chart`: the HRIS / org-chart import. Consumes text and documents plus
34
- * images and diagrams (a photographed or drawn chart goes to a vision-capable
35
- * model natively). The 10 MiB ceiling bounds the bytes handed to that model
36
- * per upload; the registry caps for its families would otherwise reach 50 MB.
42
+ * - `company-md`: context bank, goals extraction, Source Sync landing. Text and
43
+ * documents become text. (Image/diagram vision on this surface is a later,
44
+ * separate change: it needs its own extraction prompt and discovery eval.)
45
+ * - `org-chart`: HRIS / org-chart import. A photographed, drawn or PDF chart
46
+ * goes to a vision-capable model natively; 10 MiB bounds the bytes per upload.
47
+ * - `chat`: chat composer attachments. Text becomes text; a PDF and images go
48
+ * to the model natively. At most 8 files per turn. 5 MiB per file = the composer's per-file cap today;
49
+ * the request body ceiling (base64, several files) is derived from it by the
50
+ * chat PRD, not declared here. Audio/video are refused until a transcript
51
+ * path is wired.
52
+ * - `profile-image`: avatar and org-logo uploads. Images are stored, never
53
+ * extracted; 5 MiB matches the current avatar/logo cap.
37
54
  */
38
55
  export const INGESTION_SURFACES: Readonly<
39
56
  Record<IngestionSurfaceKey, IngestionSurface>
40
57
  > = {
41
58
  "company-md": {
42
59
  key: "company-md",
43
- families: ["text", "document"],
60
+ treatments: { text: "text", document: "text" },
61
+ maxBytes: 50_000_000,
44
62
  },
45
63
  "org-chart": {
46
64
  key: "org-chart",
47
- families: ["text", "document", "image", "diagram"],
48
- maxBytes: ORG_CHART_MAX_BYTES,
65
+ treatments: {
66
+ text: "text",
67
+ document: "vision",
68
+ image: "vision",
69
+ diagram: "vision",
70
+ },
71
+ maxBytes: 10 * MIB,
72
+ },
73
+ chat: {
74
+ key: "chat",
75
+ treatments: { text: "text", document: "vision", image: "vision" },
76
+ maxBytes: 5 * MIB,
77
+ // The composer sets no maxFiles today (unbounded; only the 10 MiB body cap
78
+ // limits a turn). 8 bounds per-turn vision/billing cost and the derived body
79
+ // ceiling (8 × 5 MiB × 4/3 base64 + envelope, computed by the chat PRD) while
80
+ // covering the multi-screenshot case.
81
+ maxFilesPerTurn: 8,
82
+ },
83
+ "profile-image": {
84
+ key: "profile-image",
85
+ treatments: { image: "store" },
86
+ maxBytes: 5 * MIB,
49
87
  },
50
88
  };
51
89
 
52
- /** The registry formats a surface admits, in registry order. */
53
- export function formatsFor(surface: IngestionSurfaceKey): IngestionFormat[] {
54
- const { families } = INGESTION_SURFACES[surface];
55
- return INGESTION_FORMATS.filter((entry) => families.includes(entry.family));
90
+ /** The families a surface admits, in canonical family order. */
91
+ export function familiesOf(surface: IngestionSurfaceKey): IngestionFamily[] {
92
+ const { treatments } = INGESTION_SURFACES[surface];
93
+ return FAMILY_ORDER.filter((family) => treatments[family] !== undefined);
94
+ }
95
+
96
+ /** What the surface does with this format; null = the surface refuses it. */
97
+ export function treatmentFor(
98
+ surface: IngestionSurfaceKey,
99
+ format: IngestionFormat,
100
+ ): Treatment | null {
101
+ return INGESTION_SURFACES[surface].treatments[format.family] ?? null;
56
102
  }
57
103
 
58
104
  /** True when the surface admits this registry format. */
@@ -60,13 +106,22 @@ export function surfaceAdmits(
60
106
  surface: IngestionSurfaceKey,
61
107
  format: IngestionFormat,
62
108
  ): boolean {
63
- return INGESTION_SURFACES[surface].families.includes(format.family);
109
+ return treatmentFor(surface, format) !== null;
64
110
  }
65
111
 
66
- /** Comma-joined extension string for the surface's file input `accept`. */
112
+ /** The registry formats a surface admits, in registry order. */
113
+ export function formatsFor(surface: IngestionSurfaceKey): IngestionFormat[] {
114
+ return INGESTION_FORMATS.filter((entry) => surfaceAdmits(surface, entry));
115
+ }
116
+
117
+ /**
118
+ * The `<input accept>` value for a surface: per format, its extensions then its
119
+ * MIME (".png,image/png,.jpg,.jpeg,image/jpeg,…"). Both forms are valid in
120
+ * `accept`, and a MIME-only matcher (the chat composer) can use the same string.
121
+ */
67
122
  export function acceptFor(surface: IngestionSurfaceKey): string {
68
123
  return formatsFor(surface)
69
- .flatMap((entry) => [...entry.extensions])
124
+ .flatMap((entry) => [...entry.extensions, entry.mime])
70
125
  .join(",");
71
126
  }
72
127
 
@@ -77,15 +132,13 @@ export function supportedLabelFor(surface: IngestionSurfaceKey): string {
77
132
  }
78
133
 
79
134
  /**
80
- * The transport byte ceiling for a surface: the largest registry cap among
81
- * its admitted formats, tightened to the surface ceiling when one is declared.
82
- * The per-format registry cap still applies underneath it.
135
+ * The transport byte ceiling: the largest registry cap among admitted formats,
136
+ * tightened to the (required) surface ceiling.
83
137
  */
84
138
  export function maxBytesFor(surface: IngestionSurfaceKey): number {
85
139
  const registryMax = formatsFor(surface).reduce(
86
140
  (max, entry) => Math.max(max, entry.maxBytes),
87
141
  0,
88
142
  );
89
- const ceiling = INGESTION_SURFACES[surface].maxBytes;
90
- return ceiling === undefined ? registryMax : Math.min(registryMax, ceiling);
143
+ return Math.min(registryMax, INGESTION_SURFACES[surface].maxBytes);
91
144
  }
@@ -7,7 +7,7 @@
7
7
  * private dispatch fields locally) and the app upload UI (which derives its
8
8
  * `accept` attribute and supported-formats copy from this).
9
9
  *
10
- * Types only — no runtime behavior. The backend-only normalizer routing
10
+ * Types plus the `FORMAT_CLASSES` and `TREATMENTS` vocabulary constants. The backend-only normalizer routing
11
11
  * (`canonicalKind`, `normalizerKey`) deliberately does NOT live here; it is an
12
12
  * implementation detail of the backend dispatch and would pollute the shared
13
13
  * vocabulary.
@@ -17,7 +17,7 @@
17
17
 
18
18
  /**
19
19
  * The semantic family a format belongs to. A SURFACE (an upload picker and the
20
- * route behind it) declares which families it consumes; the accept-set and the
20
+ * route behind it) declares a treatment per family it consumes; the accept-set and the
21
21
  * byte ceiling for that surface derive from the registry through this field.
22
22
  * Public and semantic — NOT the backend normalizer dispatch (`canonicalKind` /
23
23
  * `normalizerKey` stay backend-local, ADR-CONTRACTS-075).
@@ -27,6 +27,29 @@
27
27
  export type IngestionFamily =
28
28
  "text" | "document" | "image" | "diagram" | "audio" | "video";
29
29
 
30
+ /**
31
+ * The coarse, provider-agnostic kind of a format, used wherever the system
32
+ * classes a file without reading it: the Source Sync inventory `mime_class`,
33
+ * the discovery topology mix, probe groups, and the spreadsheet `source_kind`.
34
+ * Declared ON the format entry — no consumer keeps an extension → class table.
35
+ * Canonical order (the inventory CHECK and every derived list follow it).
36
+ */
37
+ export const FORMAT_CLASSES = [
38
+ "docs",
39
+ "slides",
40
+ "sheets",
41
+ "pdf",
42
+ "text",
43
+ "image",
44
+ "diagram",
45
+ "audio",
46
+ "video",
47
+ ] as const;
48
+ export type FormatClass = (typeof FORMAT_CLASSES)[number];
49
+
50
+ /** A Source Sync inventory class: a format class, or a provider shortcut row. */
51
+ export type InventoryClass = FormatClass | "shortcut";
52
+
30
53
  /**
31
54
  * One accepted upload format, keyed by its canonical MIME type.
32
55
  *
@@ -35,14 +58,17 @@ export type IngestionFamily =
35
58
  * - `extensions` are lowercase, dot-prefixed (e.g. `.docx`).
36
59
  * - `label` is the human display GROUP and is intentionally shared across
37
60
  * related formats (`.docx` + `.doc` are both `Word`; the rasters are all
38
- * `Image`; `.svg` + `.drawio` are `Diagram`).
61
+ * `Image`; `.svg` is `Diagram`).
39
62
  * - `family` is the semantic family a surface selects on; every entry has one.
63
+ * - `class` is the coarse class (one of `FORMAT_CLASSES`); every entry has one.
40
64
  */
41
65
  export interface IngestionFormat {
42
66
  /** Canonical MIME type (the registry key). */
43
67
  mime: string;
44
- /** Semantic family the format belongs to (what a surface selects on). */
68
+ /** Semantic family the format belongs to (what a surface declares a treatment for). */
45
69
  family: IngestionFamily;
70
+ /** Coarse class for classing without reading (inventory, topology, probes). */
71
+ class: FormatClass;
46
72
  /** Lowercase, dot-prefixed filename extensions for this format. */
47
73
  extensions: readonly string[];
48
74
  /** Human display group label (e.g. `Word`, `Image`, `Audio`). */
@@ -51,23 +77,62 @@ export interface IngestionFormat {
51
77
  maxBytes: number;
52
78
  }
53
79
 
54
- /** The upload surfaces that consume the registry. */
55
- export type IngestionSurfaceKey = "company-md" | "org-chart";
56
-
57
80
  /**
58
- * What one upload surface consumes: the families it admits and, optionally, a
59
- * byte ceiling tighter than the registry caps of those families.
81
+ * What a surface does with an admitted family. The backend proves at load that
82
+ * it can perform every declared (surface, family, treatment) for every admitted
83
+ * format, or it refuses to start.
60
84
  *
61
- * @see ADR-CONTRACTS-159
85
+ * - `text`: normalize to text only (a PDF is text-extracted).
86
+ * - `vision`: hand the model the richest native form — images as image parts,
87
+ * a PDF as a binary document, text-native formats (docx, csv, …) as text.
88
+ * - `transcript`: audio/video become a transcript.
89
+ * - `store`: bytes are kept as-is (after format-preserving preparation such as
90
+ * HEIC → JPEG); nothing is extracted.
91
+ */
92
+ export const TREATMENTS = ["text", "vision", "transcript", "store"] as const;
93
+ export type Treatment = (typeof TREATMENTS)[number];
94
+
95
+ /** The surfaces that consume the registry. */
96
+ export type IngestionSurfaceKey =
97
+ "company-md" | "org-chart" | "chat" | "profile-image";
98
+
99
+ /**
100
+ * One surface: the treatment it applies per admitted family, and its byte
101
+ * ceiling. `families` is derived (`familiesOf`), never declared.
62
102
  */
63
103
  export interface IngestionSurface {
64
104
  /** The surface this declaration describes. */
65
105
  key: IngestionSurfaceKey;
66
- /** Families the surface admits; a format outside them is refused (415). */
67
- families: readonly IngestionFamily[];
106
+ /** Admitted family → what the surface does with it. Absent family = refused (415). */
107
+ treatments: Readonly<Partial<Record<IngestionFamily, Treatment>>>;
108
+ /** Surface byte ceiling (required). Caps every admitted format below its registry `maxBytes`. */
109
+ maxBytes: number;
68
110
  /**
69
- * Surface-level byte ceiling. When set it caps every admitted format below
70
- * its registry `maxBytes`; absent means the registry caps apply as-is.
111
+ * Most files one turn may carry. Declared only on `chat` (required there,
112
+ * pinned by test); upload surfaces take one file per request and omit it.
71
113
  */
72
- maxBytes?: number;
114
+ maxFilesPerTurn?: number;
115
+ }
116
+
117
+ /** External sources that hand the pipeline provider-native files. */
118
+ export type IngestionProviderKey = "google-drive";
119
+
120
+ /** A provider-native type that is exported to a registry format before ingestion. */
121
+ export interface ProviderNativeFormat {
122
+ /** The provider's own MIME (e.g. a Google Workspace type). */
123
+ mime: string;
124
+ /** Human label for the native type (e.g. `Google Doc`). */
125
+ label: string;
126
+ /** Registry MIME the provider exports this type to. Must resolve via `byMime`. */
127
+ exportsTo: string;
128
+ }
129
+
130
+ /** One provider: its native types and the structural container MIMEs it lists. */
131
+ export interface IngestionProvider {
132
+ /** The provider this declaration describes. */
133
+ key: IngestionProviderKey;
134
+ /** Native types the provider exports to registry formats. */
135
+ nativeFormats: readonly ProviderNativeFormat[];
136
+ /** Structural (non-file) MIMEs the provider lists: folders and shortcuts. */
137
+ containerMimes: { readonly folder: string; readonly shortcut: string };
73
138
  }