@singi-labs/sifa-sdk 0.19.96 → 0.19.97

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 (38) hide show
  1. package/dist/index.cjs +1 -1
  2. package/dist/index.js +1 -1
  3. package/dist/jev/index.cjs +20 -0
  4. package/dist/jev/index.cjs.map +1 -1
  5. package/dist/jev/index.d.cts +29 -1
  6. package/dist/jev/index.d.ts +29 -1
  7. package/dist/jev/index.js +20 -0
  8. package/dist/jev/index.js.map +1 -1
  9. package/dist/jev/issue.cjs.map +1 -1
  10. package/dist/query/fetchers/admin-review-queues.js +3 -1
  11. package/dist/query/fetchers/admin-review-queues.js.map +1 -1
  12. package/dist/query/fetchers/index.cjs +20 -2
  13. package/dist/query/fetchers/index.cjs.map +1 -1
  14. package/dist/query/fetchers/index.d.cts +1 -1
  15. package/dist/query/fetchers/index.d.ts +1 -1
  16. package/dist/query/fetchers/index.js +4 -0
  17. package/dist/query/fetchers/index.js.map +1 -1
  18. package/dist/query/fetchers/profile-completeness.js +21 -2
  19. package/dist/query/fetchers/profile-completeness.js.map +1 -1
  20. package/dist/query/hooks/index.cjs +53 -2
  21. package/dist/query/hooks/index.cjs.map +1 -1
  22. package/dist/query/hooks/index.d.cts +2 -2
  23. package/dist/query/hooks/index.d.ts +2 -2
  24. package/dist/query/hooks/index.js +7 -1
  25. package/dist/query/hooks/index.js.map +1 -1
  26. package/dist/query/hooks/use-profile-completeness.js +43 -1
  27. package/dist/query/hooks/use-profile-completeness.js.map +1 -1
  28. package/dist/query/index.cjs +55 -2
  29. package/dist/query/index.cjs.map +1 -1
  30. package/dist/query/index.d.cts +3 -3
  31. package/dist/query/index.d.ts +3 -3
  32. package/dist/query/index.js +11 -1
  33. package/dist/query/index.js.map +1 -1
  34. package/dist/{use-user-settings-CBH1CeDP.d.cts → use-user-settings-CNbF2SR5.d.cts} +13 -2
  35. package/dist/{use-user-settings-CFhaGcAq.d.ts → use-user-settings-GNjzBIGz.d.ts} +13 -2
  36. package/dist/{user-settings-D1zKOWa2.d.ts → user-settings-D77MWX_w.d.ts} +20 -1
  37. package/dist/{user-settings-ChPm0BFN.d.cts → user-settings-DWNbF59w.d.cts} +20 -1
  38. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -7696,7 +7696,7 @@ function describeSifaRecord(collection, value) {
7696
7696
  }
7697
7697
 
7698
7698
  // src/index.ts
7699
- var SIFA_SDK_VERSION = "0.19.96";
7699
+ var SIFA_SDK_VERSION = "0.19.97";
7700
7700
 
7701
7701
  exports.ACTIVITY_TIERS = ACTIVITY_TIERS;
7702
7702
  exports.ACTIVITY_VERBS = ACTIVITY_VERBS;
package/dist/index.js CHANGED
@@ -379,7 +379,7 @@ import {
379
379
  roadmapIssueUrl
380
380
  } from "./roadmap/index.js";
381
381
  import { SIFA_REPO_GROUPS, describeSifaRecord, repoGroupForCollection } from "./repo/index.js";
382
- var SIFA_SDK_VERSION = "0.19.96";
382
+ var SIFA_SDK_VERSION = "0.19.97";
383
383
  export {
384
384
  ACTIVITY_TIERS,
385
385
  ACTIVITY_VERBS,
@@ -68,6 +68,23 @@ var ORG_TYPE_TAXONOMY = {
68
68
  self_employed: "Sole proprietor, freelancer, or self-employed individual",
69
69
  other: "None of the above, or not enough information to decide"
70
70
  };
71
+ var SECTOR_ORG_TYPE_INDUSTRY_HINTS = {
72
+ healthcare_provider: ["health", "hospital", "medical", "clinic", "nursing", "dental"],
73
+ educational: ["educat", "school", "universit", "college", "academ"],
74
+ government: ["government", "public_sector", "public sector", "public administration", "defen"],
75
+ research_institute: ["research", "scien", "laborator", "educat"],
76
+ religious: ["relig", "church", "faith", "nonprofit"],
77
+ political: ["politic", "nonprofit"]
78
+ };
79
+ function isInferredOrgTypePlausible(orgType, ctx) {
80
+ if (orgType === "other" || !Object.hasOwn(ORG_TYPE_TAXONOMY, orgType)) return false;
81
+ const hints = SECTOR_ORG_TYPE_INDUSTRY_HINTS[orgType];
82
+ if (!hints) return true;
83
+ if (!ctx.hasDescription || !ctx.industry) return false;
84
+ const industry = ctx.industry.toLowerCase();
85
+ return hints.some((hint) => industry.includes(hint));
86
+ }
87
+ var INFERRED_FACT_FIELDS = ["industry", "sizeBand", "orgType"];
71
88
  var SIZE_BAND_TAXONOMY = {
72
89
  solo: "1 person",
73
90
  small: "2 to 10 people",
@@ -107,15 +124,18 @@ function pickChoiceAboveFloor(answer, opts = {}) {
107
124
  exports.FIRMOGRAPHIC_CONFIDENCE_FLOOR = FIRMOGRAPHIC_CONFIDENCE_FLOOR;
108
125
  exports.INDUSTRY_CHOICE_QUESTION = INDUSTRY_CHOICE_QUESTION;
109
126
  exports.INDUSTRY_TAXONOMY = INDUSTRY_TAXONOMY;
127
+ exports.INFERRED_FACT_FIELDS = INFERRED_FACT_FIELDS;
110
128
  exports.ORG_PAIR_NOUL_QUESTION = ORG_PAIR_NOUL_QUESTION;
111
129
  exports.ORG_TYPE_CHOICE_QUESTION = ORG_TYPE_CHOICE_QUESTION;
112
130
  exports.ORG_TYPE_TAXONOMY = ORG_TYPE_TAXONOMY;
113
131
  exports.SAME_ORG_THRESHOLD = SAME_ORG_THRESHOLD;
132
+ exports.SECTOR_ORG_TYPE_INDUSTRY_HINTS = SECTOR_ORG_TYPE_INDUSTRY_HINTS;
114
133
  exports.SIZE_BAND_CHOICE_QUESTION = SIZE_BAND_CHOICE_QUESTION;
115
134
  exports.SIZE_BAND_TAXONOMY = SIZE_BAND_TAXONOMY;
116
135
  exports.buildFirmographicState = buildFirmographicState;
117
136
  exports.buildOrgPairState = buildOrgPairState;
118
137
  exports.choiceAnswerSchema = choiceAnswerSchema;
138
+ exports.isInferredOrgTypePlausible = isInferredOrgTypePlausible;
119
139
  exports.isLikelySameOrg = isLikelySameOrg;
120
140
  exports.noulAnswerSchema = noulAnswerSchema;
121
141
  exports.pickChoiceAboveFloor = pickChoiceAboveFloor;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/jev/index.ts"],"names":["z"],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * `@singi-labs/sifa-sdk/jev` -- TypeSafe System One (Jev) question builders,\n * taxonomies, result schemas, and threshold predicates for Sifa's organisation\n * intelligence.\n *\n * This subpath is pure: it defines the typed *questions* to ask Jev, validates\n * the *answers*, and holds the policy constants (thresholds, taxonomies) that\n * turn a raw judgment into a decision. It performs no network calls and needs\n * no API key, so it is safe to import anywhere. The actual `systemOne` call\n * lives server-side in sifa-api, which passes an API key that must never reach\n * the browser.\n *\n * Judgment shapes:\n * - Duplicate-org detection: a single Noul over a candidate pair.\n * - Firmographic classification: independent Choice questions (industry, type,\n * size) asked together over one org's state.\n *\n * See docs.typesafe.ai for the wire contract. Typed output guarantees the\n * interface, not truth: validate thresholds on labelled Sifa data before\n * enabling any write.\n */\n\nimport { z } from 'zod';\n\n// ---------------------------------------------------------------------------\n// Wire question types\n// ---------------------------------------------------------------------------\n\n/** A yes/no judgment. `criteria` clarifies what true vs false means. */\nexport interface NoulQuestion {\n type: 'noul';\n instructions: string;\n criteria?: { true: string; false: string };\n}\n\n/** A single-selection judgment. `criteria` maps each option to its description. */\nexport interface ChoiceQuestion {\n type: 'choice';\n instructions: string;\n criteria: Record<string, string>;\n}\n\n// ---------------------------------------------------------------------------\n// Answer schemas (validate Jev's response before code trusts it)\n// ---------------------------------------------------------------------------\n\n/** Jev's Noul answer: `noul` is the probability that the answer is yes (0..1). */\nexport const noulAnswerSchema = z.object({\n type: z.literal('noul'),\n noul: z.number().min(0).max(1),\n});\nexport type NoulAnswer = z.infer<typeof noulAnswerSchema>;\n\n/**\n * Jev's Choice answer: `choice` is the highest-probability option, `confidence`\n * summarises how concentrated the distribution is, `probabilities` sums to 1.\n */\nexport const choiceAnswerSchema = z.object({\n type: z.literal('choice'),\n choice: z.string(),\n confidence: z.number().min(0).max(1),\n probabilities: z.record(z.string(), z.number()),\n});\nexport type ChoiceAnswer = z.infer<typeof choiceAnswerSchema>;\n\n// ---------------------------------------------------------------------------\n// Duplicate-org detection (Noul) -- ticket #560\n// ---------------------------------------------------------------------------\n\n/** Public, non-user fields we compare when judging whether two orgs are the same. */\nexport interface OrgRecordInput {\n name: string;\n domain?: string;\n location?: string;\n description?: string;\n}\n\n/** Build a plain object with only the defined keys (Jev sees no `undefined`). */\nfunction compact<T extends object>(obj: T): Partial<T> {\n const out: Partial<T> = {};\n for (const [k, v] of Object.entries(obj)) {\n if (v !== undefined && v !== null && v !== '') out[k as keyof T] = v as T[keyof T];\n }\n return out;\n}\n\n/**\n * State for the org-pair judgment. Both records live under stable named fields\n * so the question can reference them unambiguously.\n */\nexport function buildOrgPairState(\n a: OrgRecordInput,\n b: OrgRecordInput,\n): { orgA: Partial<OrgRecordInput>; orgB: Partial<OrgRecordInput> } {\n return { orgA: compact(a), orgB: compact(b) };\n}\n\n/** Static Noul question paired with {@link buildOrgPairState}. */\nexport const ORG_PAIR_NOUL_QUESTION: NoulQuestion = {\n type: 'noul',\n instructions:\n 'The state holds two organisation records, orgA and orgB. Do they refer to the same real-world organisation?',\n criteria: {\n true: 'Same organisation, e.g. a legal-suffix or abbreviation variant of the same name, or the same primary domain.',\n false:\n 'Distinct organisations, e.g. a shared word in the name but different domains, locations, or lines of business.',\n },\n};\n\n/**\n * Threshold favouring precision: a wrong merge is worse than a missed one.\n * Validate on labelled pairs (ticket #560) before enabling merges in prod.\n */\nexport const SAME_ORG_THRESHOLD = 0.85;\n\n/** True when the Noul probability clears the merge threshold. */\nexport function isLikelySameOrg(noul: number, threshold: number = SAME_ORG_THRESHOLD): boolean {\n return noul >= threshold;\n}\n\n// ---------------------------------------------------------------------------\n// Firmographic classification (Choice) -- ticket #561\n// ---------------------------------------------------------------------------\n\n/** Industry taxonomy. Single source of truth; `other` is the no-match option. */\nexport const INDUSTRY_TAXONOMY: Record<string, string> = {\n technology: 'Software, IT services, internet, hardware, telecoms',\n finance: 'Banking, insurance, investment, fintech, accounting',\n healthcare: 'Medical care, pharma, biotech, health services',\n education: 'Schools, universities, training, edtech',\n manufacturing: 'Industrial production, hardware, materials, automotive',\n retail: 'Retail, ecommerce, consumer goods, hospitality, food',\n media: 'Media, publishing, entertainment, marketing, advertising',\n energy: 'Energy, utilities, oil and gas, renewables',\n construction: 'Construction, real estate, architecture, engineering',\n transport: 'Transport, logistics, shipping, aviation',\n professional_services: 'Legal, consulting, HR, business services',\n public_sector: 'Government, defence, public administration',\n nonprofit: 'Charities, NGOs, foundations, associations',\n agriculture: 'Agriculture, farming, fishing, forestry',\n other: 'None of the above, or not enough information to decide',\n};\n\n/**\n * Organisation type (the org's FORM, not its sector -- a hospital's type is\n * healthcare_provider while its industry is healthcare). `other` is the no-match\n * option. Prefer the registry `legalForm` where it already encodes the form.\n */\nexport const ORG_TYPE_TAXONOMY: Record<string, string> = {\n company: 'For-profit company or corporation',\n cooperative: 'Member-owned co-operative (e.g. eG, SCOP, mutual, credit union)',\n nonprofit: 'Nonprofit, charity, or NGO',\n foundation: 'Grant-making or endowed foundation (e.g. Stiftung, fonds)',\n government: 'Government body, agency, or public-sector authority',\n educational: 'School, college, or university',\n research_institute: 'Independent research institute or laboratory',\n healthcare_provider: 'Hospital, clinic, or healthcare provider',\n professional_association: 'Professional association, trade body, or trade union',\n agency: 'Agency, consultancy, or professional-services firm',\n religious: 'Religious organisation',\n political: 'Political party or organisation',\n self_employed: 'Sole proprietor, freelancer, or self-employed individual',\n other: 'None of the above, or not enough information to decide',\n};\n\n/** Ordered employee-size bands; `unknown` is the no-match option. */\nexport const SIZE_BAND_TAXONOMY: Record<string, string> = {\n solo: '1 person',\n small: '2 to 10 people',\n medium: '11 to 50 people',\n large: '51 to 250 people',\n enterprise: 'More than 250 people',\n unknown: 'Not enough information to decide',\n};\n\nfunction choiceQuestion(instructions: string, criteria: Record<string, string>): ChoiceQuestion {\n return { type: 'choice', instructions, criteria };\n}\n\nexport const INDUSTRY_CHOICE_QUESTION = choiceQuestion(\n 'Based on the org state, which single industry best describes this organisation?',\n INDUSTRY_TAXONOMY,\n);\n\nexport const ORG_TYPE_CHOICE_QUESTION = choiceQuestion(\n 'Based on the org state, which single organisation type (its form, not its industry sector) best describes this organisation?',\n ORG_TYPE_TAXONOMY,\n);\n\nexport const SIZE_BAND_CHOICE_QUESTION = choiceQuestion(\n 'Based on the org state, which employee-size band best fits this organisation?',\n SIZE_BAND_TAXONOMY,\n);\n\n/**\n * Input for firmographic classification. `enrichmentText` (the org's description)\n * is the strongest evidence, but it is optional: many registry orgs have only a\n * name, domain, country, or link set. Passing those lets Jev classify from the\n * signal we hold rather than skipping the org for want of a description. A bare\n * name alone rarely suffices; a name plus a domain or country often does.\n */\nexport interface FirmographicInput {\n name: string;\n domain?: string;\n country?: string;\n /** Featured/external link labels or hosts (e.g. 'linkedin', 'wikipedia'). */\n links?: string[];\n /** An already-known industry, when set (helps disambiguate type/size). */\n industry?: string;\n enrichmentText?: string;\n}\n\n/**\n * State for the firmographic questions. Drops absent/empty fields (including\n * empty arrays) so Jev never sees a null or `[]` it would have to reason about.\n */\nexport function buildFirmographicState(org: FirmographicInput): Partial<FirmographicInput> {\n const compacted = compact(org);\n if (compacted.links && compacted.links.length === 0) delete compacted.links;\n return compacted;\n}\n\n/**\n * Below this confidence a firmographic field is left unset rather than guessed.\n * Validate per field on labelled orgs (ticket #561).\n */\nexport const FIRMOGRAPHIC_CONFIDENCE_FLOOR = 0.6;\n\n/**\n * Select the classified option, or `null` to leave the field unset. Returns\n * null when confidence is below the floor, or when the selected option is the\n * taxonomy's no-match option.\n */\nexport function pickChoiceAboveFloor(\n answer: ChoiceAnswer,\n opts: { floor?: number; noMatch?: string } = {},\n): string | null {\n const floor = opts.floor ?? FIRMOGRAPHIC_CONFIDENCE_FLOOR;\n if (answer.confidence < floor) return null;\n if (opts.noMatch !== undefined && answer.choice === opts.noMatch) return null;\n return answer.choice;\n}\n"]}
1
+ {"version":3,"sources":["../../src/jev/index.ts"],"names":["z"],"mappings":";;;;;AA+CO,IAAM,gBAAA,GAAmBA,MAAE,MAAA,CAAO;AAAA,EACvC,IAAA,EAAMA,KAAA,CAAE,OAAA,CAAQ,MAAM,CAAA;AAAA,EACtB,IAAA,EAAMA,MAAE,MAAA,EAAO,CAAE,IAAI,CAAC,CAAA,CAAE,IAAI,CAAC;AAC/B,CAAC;AAOM,IAAM,kBAAA,GAAqBA,MAAE,MAAA,CAAO;AAAA,EACzC,IAAA,EAAMA,KAAA,CAAE,OAAA,CAAQ,QAAQ,CAAA;AAAA,EACxB,MAAA,EAAQA,MAAE,MAAA,EAAO;AAAA,EACjB,UAAA,EAAYA,MAAE,MAAA,EAAO,CAAE,IAAI,CAAC,CAAA,CAAE,IAAI,CAAC,CAAA;AAAA,EACnC,aAAA,EAAeA,MAAE,MAAA,CAAOA,KAAA,CAAE,QAAO,EAAGA,KAAA,CAAE,QAAQ;AAChD,CAAC;AAgBD,SAAS,QAA0B,GAAA,EAAoB;AACrD,EAAA,MAAM,MAAkB,EAAC;AACzB,EAAA,KAAA,MAAW,CAAC,CAAA,EAAG,CAAC,KAAK,MAAA,CAAO,OAAA,CAAQ,GAAG,CAAA,EAAG;AACxC,IAAA,IAAI,CAAA,KAAM,UAAa,CAAA,KAAM,IAAA,IAAQ,MAAM,EAAA,EAAI,GAAA,CAAI,CAAY,CAAA,GAAI,CAAA;AAAA,EACrE;AACA,EAAA,OAAO,GAAA;AACT;AAMO,SAAS,iBAAA,CACd,GACA,CAAA,EACkE;AAClE,EAAA,OAAO,EAAE,MAAM,OAAA,CAAQ,CAAC,GAAG,IAAA,EAAM,OAAA,CAAQ,CAAC,CAAA,EAAE;AAC9C;AAGO,IAAM,sBAAA,GAAuC;AAAA,EAClD,IAAA,EAAM,MAAA;AAAA,EACN,YAAA,EACE,6GAAA;AAAA,EACF,QAAA,EAAU;AAAA,IACR,IAAA,EAAM,8GAAA;AAAA,IACN,KAAA,EACE;AAAA;AAEN;AAMO,IAAM,kBAAA,GAAqB;AAG3B,SAAS,eAAA,CAAgB,IAAA,EAAc,SAAA,GAAoB,kBAAA,EAA6B;AAC7F,EAAA,OAAO,IAAA,IAAQ,SAAA;AACjB;AAOO,IAAM,iBAAA,GAA4C;AAAA,EACvD,UAAA,EAAY,qDAAA;AAAA,EACZ,OAAA,EAAS,qDAAA;AAAA,EACT,UAAA,EAAY,gDAAA;AAAA,EACZ,SAAA,EAAW,yCAAA;AAAA,EACX,aAAA,EAAe,wDAAA;AAAA,EACf,MAAA,EAAQ,sDAAA;AAAA,EACR,KAAA,EAAO,0DAAA;AAAA,EACP,MAAA,EAAQ,4CAAA;AAAA,EACR,YAAA,EAAc,sDAAA;AAAA,EACd,SAAA,EAAW,0CAAA;AAAA,EACX,qBAAA,EAAuB,0CAAA;AAAA,EACvB,aAAA,EAAe,4CAAA;AAAA,EACf,SAAA,EAAW,4CAAA;AAAA,EACX,WAAA,EAAa,yCAAA;AAAA,EACb,KAAA,EAAO;AACT;AAOO,IAAM,iBAAA,GAA4C;AAAA,EACvD,OAAA,EAAS,mCAAA;AAAA,EACT,WAAA,EAAa,iEAAA;AAAA,EACb,SAAA,EAAW,4BAAA;AAAA,EACX,UAAA,EAAY,2DAAA;AAAA,EACZ,UAAA,EAAY,qDAAA;AAAA,EACZ,WAAA,EAAa,gCAAA;AAAA,EACb,kBAAA,EAAoB,8CAAA;AAAA,EACpB,mBAAA,EAAqB,0CAAA;AAAA,EACrB,wBAAA,EAA0B,sDAAA;AAAA,EAC1B,MAAA,EAAQ,oDAAA;AAAA,EACR,SAAA,EAAW,wBAAA;AAAA,EACX,SAAA,EAAW,iCAAA;AAAA,EACX,aAAA,EAAe,0DAAA;AAAA,EACf,KAAA,EAAO;AACT;AAWO,IAAM,8BAAA,GAAoE;AAAA,EAC/E,qBAAqB,CAAC,QAAA,EAAU,YAAY,SAAA,EAAW,QAAA,EAAU,WAAW,QAAQ,CAAA;AAAA,EACpF,aAAa,CAAC,QAAA,EAAU,QAAA,EAAU,WAAA,EAAa,WAAW,QAAQ,CAAA;AAAA,EAClE,YAAY,CAAC,YAAA,EAAc,eAAA,EAAiB,eAAA,EAAiB,yBAAyB,OAAO,CAAA;AAAA,EAC7F,kBAAA,EAAoB,CAAC,UAAA,EAAY,OAAA,EAAS,aAAa,QAAQ,CAAA;AAAA,EAC/D,SAAA,EAAW,CAAC,OAAA,EAAS,QAAA,EAAU,SAAS,WAAW,CAAA;AAAA,EACnD,SAAA,EAAW,CAAC,SAAA,EAAW,WAAW;AACpC;AAUO,SAAS,0BAAA,CACd,SACA,GAAA,EACS;AACT,EAAA,IAAI,OAAA,KAAY,WAAW,CAAC,MAAA,CAAO,OAAO,iBAAA,EAAmB,OAAO,GAAG,OAAO,KAAA;AAC9E,EAAA,MAAM,KAAA,GAAQ,+BAA+B,OAAO,CAAA;AACpD,EAAA,IAAI,CAAC,OAAO,OAAO,IAAA;AACnB,EAAA,IAAI,CAAC,GAAA,CAAI,cAAA,IAAkB,CAAC,GAAA,CAAI,UAAU,OAAO,KAAA;AACjD,EAAA,MAAM,QAAA,GAAW,GAAA,CAAI,QAAA,CAAS,WAAA,EAAY;AAC1C,EAAA,OAAO,MAAM,IAAA,CAAK,CAAC,SAAS,QAAA,CAAS,QAAA,CAAS,IAAI,CAAC,CAAA;AACrD;AAMO,IAAM,oBAAA,GAAuB,CAAC,UAAA,EAAY,UAAA,EAAY,SAAS;AAI/D,IAAM,kBAAA,GAA6C;AAAA,EACxD,IAAA,EAAM,UAAA;AAAA,EACN,KAAA,EAAO,gBAAA;AAAA,EACP,MAAA,EAAQ,iBAAA;AAAA,EACR,KAAA,EAAO,kBAAA;AAAA,EACP,UAAA,EAAY,sBAAA;AAAA,EACZ,OAAA,EAAS;AACX;AAEA,SAAS,cAAA,CAAe,cAAsB,QAAA,EAAkD;AAC9F,EAAA,OAAO,EAAE,IAAA,EAAM,QAAA,EAAU,YAAA,EAAc,QAAA,EAAS;AAClD;AAEO,IAAM,wBAAA,GAA2B,cAAA;AAAA,EACtC,iFAAA;AAAA,EACA;AACF;AAEO,IAAM,wBAAA,GAA2B,cAAA;AAAA,EACtC,8HAAA;AAAA,EACA;AACF;AAEO,IAAM,yBAAA,GAA4B,cAAA;AAAA,EACvC,+EAAA;AAAA,EACA;AACF;AAwBO,SAAS,uBAAuB,GAAA,EAAoD;AACzF,EAAA,MAAM,SAAA,GAAY,QAAQ,GAAG,CAAA;AAC7B,EAAA,IAAI,UAAU,KAAA,IAAS,SAAA,CAAU,MAAM,MAAA,KAAW,CAAA,SAAU,SAAA,CAAU,KAAA;AACtE,EAAA,OAAO,SAAA;AACT;AAMO,IAAM,6BAAA,GAAgC;AAOtC,SAAS,oBAAA,CACd,MAAA,EACA,IAAA,GAA6C,EAAC,EAC/B;AACf,EAAA,MAAM,KAAA,GAAQ,KAAK,KAAA,IAAS,6BAAA;AAC5B,EAAA,IAAI,MAAA,CAAO,UAAA,GAAa,KAAA,EAAO,OAAO,IAAA;AACtC,EAAA,IAAI,KAAK,OAAA,KAAY,MAAA,IAAa,OAAO,MAAA,KAAW,IAAA,CAAK,SAAS,OAAO,IAAA;AACzE,EAAA,OAAO,MAAA,CAAO,MAAA;AAChB","file":"index.cjs","sourcesContent":["/**\n * `@singi-labs/sifa-sdk/jev` -- TypeSafe System One (Jev) question builders,\n * taxonomies, result schemas, and threshold predicates for Sifa's organisation\n * intelligence.\n *\n * This subpath is pure: it defines the typed *questions* to ask Jev, validates\n * the *answers*, and holds the policy constants (thresholds, taxonomies) that\n * turn a raw judgment into a decision. It performs no network calls and needs\n * no API key, so it is safe to import anywhere. The actual `systemOne` call\n * lives server-side in sifa-api, which passes an API key that must never reach\n * the browser.\n *\n * Judgment shapes:\n * - Duplicate-org detection: a single Noul over a candidate pair.\n * - Firmographic classification: independent Choice questions (industry, type,\n * size) asked together over one org's state.\n *\n * See docs.typesafe.ai for the wire contract. Typed output guarantees the\n * interface, not truth: validate thresholds on labelled Sifa data before\n * enabling any write.\n */\n\nimport { z } from 'zod';\n\n// ---------------------------------------------------------------------------\n// Wire question types\n// ---------------------------------------------------------------------------\n\n/** A yes/no judgment. `criteria` clarifies what true vs false means. */\nexport interface NoulQuestion {\n type: 'noul';\n instructions: string;\n criteria?: { true: string; false: string };\n}\n\n/** A single-selection judgment. `criteria` maps each option to its description. */\nexport interface ChoiceQuestion {\n type: 'choice';\n instructions: string;\n criteria: Record<string, string>;\n}\n\n// ---------------------------------------------------------------------------\n// Answer schemas (validate Jev's response before code trusts it)\n// ---------------------------------------------------------------------------\n\n/** Jev's Noul answer: `noul` is the probability that the answer is yes (0..1). */\nexport const noulAnswerSchema = z.object({\n type: z.literal('noul'),\n noul: z.number().min(0).max(1),\n});\nexport type NoulAnswer = z.infer<typeof noulAnswerSchema>;\n\n/**\n * Jev's Choice answer: `choice` is the highest-probability option, `confidence`\n * summarises how concentrated the distribution is, `probabilities` sums to 1.\n */\nexport const choiceAnswerSchema = z.object({\n type: z.literal('choice'),\n choice: z.string(),\n confidence: z.number().min(0).max(1),\n probabilities: z.record(z.string(), z.number()),\n});\nexport type ChoiceAnswer = z.infer<typeof choiceAnswerSchema>;\n\n// ---------------------------------------------------------------------------\n// Duplicate-org detection (Noul) -- ticket #560\n// ---------------------------------------------------------------------------\n\n/** Public, non-user fields we compare when judging whether two orgs are the same. */\nexport interface OrgRecordInput {\n name: string;\n domain?: string;\n location?: string;\n description?: string;\n}\n\n/** Build a plain object with only the defined keys (Jev sees no `undefined`). */\nfunction compact<T extends object>(obj: T): Partial<T> {\n const out: Partial<T> = {};\n for (const [k, v] of Object.entries(obj)) {\n if (v !== undefined && v !== null && v !== '') out[k as keyof T] = v as T[keyof T];\n }\n return out;\n}\n\n/**\n * State for the org-pair judgment. Both records live under stable named fields\n * so the question can reference them unambiguously.\n */\nexport function buildOrgPairState(\n a: OrgRecordInput,\n b: OrgRecordInput,\n): { orgA: Partial<OrgRecordInput>; orgB: Partial<OrgRecordInput> } {\n return { orgA: compact(a), orgB: compact(b) };\n}\n\n/** Static Noul question paired with {@link buildOrgPairState}. */\nexport const ORG_PAIR_NOUL_QUESTION: NoulQuestion = {\n type: 'noul',\n instructions:\n 'The state holds two organisation records, orgA and orgB. Do they refer to the same real-world organisation?',\n criteria: {\n true: 'Same organisation, e.g. a legal-suffix or abbreviation variant of the same name, or the same primary domain.',\n false:\n 'Distinct organisations, e.g. a shared word in the name but different domains, locations, or lines of business.',\n },\n};\n\n/**\n * Threshold favouring precision: a wrong merge is worse than a missed one.\n * Validate on labelled pairs (ticket #560) before enabling merges in prod.\n */\nexport const SAME_ORG_THRESHOLD = 0.85;\n\n/** True when the Noul probability clears the merge threshold. */\nexport function isLikelySameOrg(noul: number, threshold: number = SAME_ORG_THRESHOLD): boolean {\n return noul >= threshold;\n}\n\n// ---------------------------------------------------------------------------\n// Firmographic classification (Choice) -- ticket #561\n// ---------------------------------------------------------------------------\n\n/** Industry taxonomy. Single source of truth; `other` is the no-match option. */\nexport const INDUSTRY_TAXONOMY: Record<string, string> = {\n technology: 'Software, IT services, internet, hardware, telecoms',\n finance: 'Banking, insurance, investment, fintech, accounting',\n healthcare: 'Medical care, pharma, biotech, health services',\n education: 'Schools, universities, training, edtech',\n manufacturing: 'Industrial production, hardware, materials, automotive',\n retail: 'Retail, ecommerce, consumer goods, hospitality, food',\n media: 'Media, publishing, entertainment, marketing, advertising',\n energy: 'Energy, utilities, oil and gas, renewables',\n construction: 'Construction, real estate, architecture, engineering',\n transport: 'Transport, logistics, shipping, aviation',\n professional_services: 'Legal, consulting, HR, business services',\n public_sector: 'Government, defence, public administration',\n nonprofit: 'Charities, NGOs, foundations, associations',\n agriculture: 'Agriculture, farming, fishing, forestry',\n other: 'None of the above, or not enough information to decide',\n};\n\n/**\n * Organisation type (the org's FORM, not its sector -- a hospital's type is\n * healthcare_provider while its industry is healthcare). `other` is the no-match\n * option. Prefer the registry `legalForm` where it already encodes the form.\n */\nexport const ORG_TYPE_TAXONOMY: Record<string, string> = {\n company: 'For-profit company or corporation',\n cooperative: 'Member-owned co-operative (e.g. eG, SCOP, mutual, credit union)',\n nonprofit: 'Nonprofit, charity, or NGO',\n foundation: 'Grant-making or endowed foundation (e.g. Stiftung, fonds)',\n government: 'Government body, agency, or public-sector authority',\n educational: 'School, college, or university',\n research_institute: 'Independent research institute or laboratory',\n healthcare_provider: 'Hospital, clinic, or healthcare provider',\n professional_association: 'Professional association, trade body, or trade union',\n agency: 'Agency, consultancy, or professional-services firm',\n religious: 'Religious organisation',\n political: 'Political party or organisation',\n self_employed: 'Sole proprietor, freelancer, or self-employed individual',\n other: 'None of the above, or not enough information to decide',\n};\n\n/**\n * Org types that also imply a sector. Jev can pick one that contradicts the\n * org's industry (a software company typed `healthcare_provider`), especially\n * from thin, description-less input. Each maps to lowercase substrings, one of\n * which the org's industry must contain for the inferred type to be shown.\n * Industry values are free text (Jev tokens like `public_sector`, or registry\n * labels like \"Hospital & Health Care\"), hence substring hints. Types not listed\n * here describe a legal or organisational form only and need no sector match.\n */\nexport const SECTOR_ORG_TYPE_INDUSTRY_HINTS: Record<string, readonly string[]> = {\n healthcare_provider: ['health', 'hospital', 'medical', 'clinic', 'nursing', 'dental'],\n educational: ['educat', 'school', 'universit', 'college', 'academ'],\n government: ['government', 'public_sector', 'public sector', 'public administration', 'defen'],\n research_institute: ['research', 'scien', 'laborator', 'educat'],\n religious: ['relig', 'church', 'faith', 'nonprofit'],\n political: ['politic', 'nonprofit'],\n};\n\n/**\n * Whether a Jev-inferred org type is plausible enough to store or show.\n *\n * - The no-match option and unknown tokens are never plausible.\n * - Form types (company, nonprofit, ...) are always plausible.\n * - Sector-bound types need an org description (thin input is where Jev\n * guesses wrong) AND an industry that matches the type's sector.\n */\nexport function isInferredOrgTypePlausible(\n orgType: string,\n ctx: { industry?: string | null; hasDescription: boolean },\n): boolean {\n if (orgType === 'other' || !Object.hasOwn(ORG_TYPE_TAXONOMY, orgType)) return false;\n const hints = SECTOR_ORG_TYPE_INDUSTRY_HINTS[orgType];\n if (!hints) return true;\n if (!ctx.hasDescription || !ctx.industry) return false;\n const industry = ctx.industry.toLowerCase();\n return hints.some((hint) => industry.includes(hint));\n}\n\n/**\n * Jev-inferred company facts a signed-in visitor can dispute (mark as wrong).\n * A dispute hides the inferred value; it never touches registry or owner data.\n */\nexport const INFERRED_FACT_FIELDS = ['industry', 'sizeBand', 'orgType'] as const;\nexport type InferredFactField = (typeof INFERRED_FACT_FIELDS)[number];\n\n/** Ordered employee-size bands; `unknown` is the no-match option. */\nexport const SIZE_BAND_TAXONOMY: Record<string, string> = {\n solo: '1 person',\n small: '2 to 10 people',\n medium: '11 to 50 people',\n large: '51 to 250 people',\n enterprise: 'More than 250 people',\n unknown: 'Not enough information to decide',\n};\n\nfunction choiceQuestion(instructions: string, criteria: Record<string, string>): ChoiceQuestion {\n return { type: 'choice', instructions, criteria };\n}\n\nexport const INDUSTRY_CHOICE_QUESTION = choiceQuestion(\n 'Based on the org state, which single industry best describes this organisation?',\n INDUSTRY_TAXONOMY,\n);\n\nexport const ORG_TYPE_CHOICE_QUESTION = choiceQuestion(\n 'Based on the org state, which single organisation type (its form, not its industry sector) best describes this organisation?',\n ORG_TYPE_TAXONOMY,\n);\n\nexport const SIZE_BAND_CHOICE_QUESTION = choiceQuestion(\n 'Based on the org state, which employee-size band best fits this organisation?',\n SIZE_BAND_TAXONOMY,\n);\n\n/**\n * Input for firmographic classification. `enrichmentText` (the org's description)\n * is the strongest evidence, but it is optional: many registry orgs have only a\n * name, domain, country, or link set. Passing those lets Jev classify from the\n * signal we hold rather than skipping the org for want of a description. A bare\n * name alone rarely suffices; a name plus a domain or country often does.\n */\nexport interface FirmographicInput {\n name: string;\n domain?: string;\n country?: string;\n /** Featured/external link labels or hosts (e.g. 'linkedin', 'wikipedia'). */\n links?: string[];\n /** An already-known industry, when set (helps disambiguate type/size). */\n industry?: string;\n enrichmentText?: string;\n}\n\n/**\n * State for the firmographic questions. Drops absent/empty fields (including\n * empty arrays) so Jev never sees a null or `[]` it would have to reason about.\n */\nexport function buildFirmographicState(org: FirmographicInput): Partial<FirmographicInput> {\n const compacted = compact(org);\n if (compacted.links && compacted.links.length === 0) delete compacted.links;\n return compacted;\n}\n\n/**\n * Below this confidence a firmographic field is left unset rather than guessed.\n * Validate per field on labelled orgs (ticket #561).\n */\nexport const FIRMOGRAPHIC_CONFIDENCE_FLOOR = 0.6;\n\n/**\n * Select the classified option, or `null` to leave the field unset. Returns\n * null when confidence is below the floor, or when the selected option is the\n * taxonomy's no-match option.\n */\nexport function pickChoiceAboveFloor(\n answer: ChoiceAnswer,\n opts: { floor?: number; noMatch?: string } = {},\n): string | null {\n const floor = opts.floor ?? FIRMOGRAPHIC_CONFIDENCE_FLOOR;\n if (answer.confidence < floor) return null;\n if (opts.noMatch !== undefined && answer.choice === opts.noMatch) return null;\n return answer.choice;\n}\n"]}
@@ -86,6 +86,34 @@ declare const INDUSTRY_TAXONOMY: Record<string, string>;
86
86
  * option. Prefer the registry `legalForm` where it already encodes the form.
87
87
  */
88
88
  declare const ORG_TYPE_TAXONOMY: Record<string, string>;
89
+ /**
90
+ * Org types that also imply a sector. Jev can pick one that contradicts the
91
+ * org's industry (a software company typed `healthcare_provider`), especially
92
+ * from thin, description-less input. Each maps to lowercase substrings, one of
93
+ * which the org's industry must contain for the inferred type to be shown.
94
+ * Industry values are free text (Jev tokens like `public_sector`, or registry
95
+ * labels like "Hospital & Health Care"), hence substring hints. Types not listed
96
+ * here describe a legal or organisational form only and need no sector match.
97
+ */
98
+ declare const SECTOR_ORG_TYPE_INDUSTRY_HINTS: Record<string, readonly string[]>;
99
+ /**
100
+ * Whether a Jev-inferred org type is plausible enough to store or show.
101
+ *
102
+ * - The no-match option and unknown tokens are never plausible.
103
+ * - Form types (company, nonprofit, ...) are always plausible.
104
+ * - Sector-bound types need an org description (thin input is where Jev
105
+ * guesses wrong) AND an industry that matches the type's sector.
106
+ */
107
+ declare function isInferredOrgTypePlausible(orgType: string, ctx: {
108
+ industry?: string | null;
109
+ hasDescription: boolean;
110
+ }): boolean;
111
+ /**
112
+ * Jev-inferred company facts a signed-in visitor can dispute (mark as wrong).
113
+ * A dispute hides the inferred value; it never touches registry or owner data.
114
+ */
115
+ declare const INFERRED_FACT_FIELDS: readonly ["industry", "sizeBand", "orgType"];
116
+ type InferredFactField = (typeof INFERRED_FACT_FIELDS)[number];
89
117
  /** Ordered employee-size bands; `unknown` is the no-match option. */
90
118
  declare const SIZE_BAND_TAXONOMY: Record<string, string>;
91
119
  declare const INDUSTRY_CHOICE_QUESTION: ChoiceQuestion;
@@ -128,4 +156,4 @@ declare function pickChoiceAboveFloor(answer: ChoiceAnswer, opts?: {
128
156
  noMatch?: string;
129
157
  }): string | null;
130
158
 
131
- export { type ChoiceAnswer, type ChoiceQuestion, FIRMOGRAPHIC_CONFIDENCE_FLOOR, type FirmographicInput, INDUSTRY_CHOICE_QUESTION, INDUSTRY_TAXONOMY, type NoulAnswer, type NoulQuestion, ORG_PAIR_NOUL_QUESTION, ORG_TYPE_CHOICE_QUESTION, ORG_TYPE_TAXONOMY, type OrgRecordInput, SAME_ORG_THRESHOLD, SIZE_BAND_CHOICE_QUESTION, SIZE_BAND_TAXONOMY, buildFirmographicState, buildOrgPairState, choiceAnswerSchema, isLikelySameOrg, noulAnswerSchema, pickChoiceAboveFloor };
159
+ export { type ChoiceAnswer, type ChoiceQuestion, FIRMOGRAPHIC_CONFIDENCE_FLOOR, type FirmographicInput, INDUSTRY_CHOICE_QUESTION, INDUSTRY_TAXONOMY, INFERRED_FACT_FIELDS, type InferredFactField, type NoulAnswer, type NoulQuestion, ORG_PAIR_NOUL_QUESTION, ORG_TYPE_CHOICE_QUESTION, ORG_TYPE_TAXONOMY, type OrgRecordInput, SAME_ORG_THRESHOLD, SECTOR_ORG_TYPE_INDUSTRY_HINTS, SIZE_BAND_CHOICE_QUESTION, SIZE_BAND_TAXONOMY, buildFirmographicState, buildOrgPairState, choiceAnswerSchema, isInferredOrgTypePlausible, isLikelySameOrg, noulAnswerSchema, pickChoiceAboveFloor };
@@ -86,6 +86,34 @@ declare const INDUSTRY_TAXONOMY: Record<string, string>;
86
86
  * option. Prefer the registry `legalForm` where it already encodes the form.
87
87
  */
88
88
  declare const ORG_TYPE_TAXONOMY: Record<string, string>;
89
+ /**
90
+ * Org types that also imply a sector. Jev can pick one that contradicts the
91
+ * org's industry (a software company typed `healthcare_provider`), especially
92
+ * from thin, description-less input. Each maps to lowercase substrings, one of
93
+ * which the org's industry must contain for the inferred type to be shown.
94
+ * Industry values are free text (Jev tokens like `public_sector`, or registry
95
+ * labels like "Hospital & Health Care"), hence substring hints. Types not listed
96
+ * here describe a legal or organisational form only and need no sector match.
97
+ */
98
+ declare const SECTOR_ORG_TYPE_INDUSTRY_HINTS: Record<string, readonly string[]>;
99
+ /**
100
+ * Whether a Jev-inferred org type is plausible enough to store or show.
101
+ *
102
+ * - The no-match option and unknown tokens are never plausible.
103
+ * - Form types (company, nonprofit, ...) are always plausible.
104
+ * - Sector-bound types need an org description (thin input is where Jev
105
+ * guesses wrong) AND an industry that matches the type's sector.
106
+ */
107
+ declare function isInferredOrgTypePlausible(orgType: string, ctx: {
108
+ industry?: string | null;
109
+ hasDescription: boolean;
110
+ }): boolean;
111
+ /**
112
+ * Jev-inferred company facts a signed-in visitor can dispute (mark as wrong).
113
+ * A dispute hides the inferred value; it never touches registry or owner data.
114
+ */
115
+ declare const INFERRED_FACT_FIELDS: readonly ["industry", "sizeBand", "orgType"];
116
+ type InferredFactField = (typeof INFERRED_FACT_FIELDS)[number];
89
117
  /** Ordered employee-size bands; `unknown` is the no-match option. */
90
118
  declare const SIZE_BAND_TAXONOMY: Record<string, string>;
91
119
  declare const INDUSTRY_CHOICE_QUESTION: ChoiceQuestion;
@@ -128,4 +156,4 @@ declare function pickChoiceAboveFloor(answer: ChoiceAnswer, opts?: {
128
156
  noMatch?: string;
129
157
  }): string | null;
130
158
 
131
- export { type ChoiceAnswer, type ChoiceQuestion, FIRMOGRAPHIC_CONFIDENCE_FLOOR, type FirmographicInput, INDUSTRY_CHOICE_QUESTION, INDUSTRY_TAXONOMY, type NoulAnswer, type NoulQuestion, ORG_PAIR_NOUL_QUESTION, ORG_TYPE_CHOICE_QUESTION, ORG_TYPE_TAXONOMY, type OrgRecordInput, SAME_ORG_THRESHOLD, SIZE_BAND_CHOICE_QUESTION, SIZE_BAND_TAXONOMY, buildFirmographicState, buildOrgPairState, choiceAnswerSchema, isLikelySameOrg, noulAnswerSchema, pickChoiceAboveFloor };
159
+ export { type ChoiceAnswer, type ChoiceQuestion, FIRMOGRAPHIC_CONFIDENCE_FLOOR, type FirmographicInput, INDUSTRY_CHOICE_QUESTION, INDUSTRY_TAXONOMY, INFERRED_FACT_FIELDS, type InferredFactField, type NoulAnswer, type NoulQuestion, ORG_PAIR_NOUL_QUESTION, ORG_TYPE_CHOICE_QUESTION, ORG_TYPE_TAXONOMY, type OrgRecordInput, SAME_ORG_THRESHOLD, SECTOR_ORG_TYPE_INDUSTRY_HINTS, SIZE_BAND_CHOICE_QUESTION, SIZE_BAND_TAXONOMY, buildFirmographicState, buildOrgPairState, choiceAnswerSchema, isInferredOrgTypePlausible, isLikelySameOrg, noulAnswerSchema, pickChoiceAboveFloor };
package/dist/jev/index.js CHANGED
@@ -65,6 +65,23 @@ var ORG_TYPE_TAXONOMY = {
65
65
  self_employed: "Sole proprietor, freelancer, or self-employed individual",
66
66
  other: "None of the above, or not enough information to decide"
67
67
  };
68
+ var SECTOR_ORG_TYPE_INDUSTRY_HINTS = {
69
+ healthcare_provider: ["health", "hospital", "medical", "clinic", "nursing", "dental"],
70
+ educational: ["educat", "school", "universit", "college", "academ"],
71
+ government: ["government", "public_sector", "public sector", "public administration", "defen"],
72
+ research_institute: ["research", "scien", "laborator", "educat"],
73
+ religious: ["relig", "church", "faith", "nonprofit"],
74
+ political: ["politic", "nonprofit"]
75
+ };
76
+ function isInferredOrgTypePlausible(orgType, ctx) {
77
+ if (orgType === "other" || !Object.hasOwn(ORG_TYPE_TAXONOMY, orgType)) return false;
78
+ const hints = SECTOR_ORG_TYPE_INDUSTRY_HINTS[orgType];
79
+ if (!hints) return true;
80
+ if (!ctx.hasDescription || !ctx.industry) return false;
81
+ const industry = ctx.industry.toLowerCase();
82
+ return hints.some((hint) => industry.includes(hint));
83
+ }
84
+ var INFERRED_FACT_FIELDS = ["industry", "sizeBand", "orgType"];
68
85
  var SIZE_BAND_TAXONOMY = {
69
86
  solo: "1 person",
70
87
  small: "2 to 10 people",
@@ -104,15 +121,18 @@ export {
104
121
  FIRMOGRAPHIC_CONFIDENCE_FLOOR,
105
122
  INDUSTRY_CHOICE_QUESTION,
106
123
  INDUSTRY_TAXONOMY,
124
+ INFERRED_FACT_FIELDS,
107
125
  ORG_PAIR_NOUL_QUESTION,
108
126
  ORG_TYPE_CHOICE_QUESTION,
109
127
  ORG_TYPE_TAXONOMY,
110
128
  SAME_ORG_THRESHOLD,
129
+ SECTOR_ORG_TYPE_INDUSTRY_HINTS,
111
130
  SIZE_BAND_CHOICE_QUESTION,
112
131
  SIZE_BAND_TAXONOMY,
113
132
  buildFirmographicState,
114
133
  buildOrgPairState,
115
134
  choiceAnswerSchema,
135
+ isInferredOrgTypePlausible,
116
136
  isLikelySameOrg,
117
137
  noulAnswerSchema,
118
138
  pickChoiceAboveFloor
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/jev/index.ts"],"sourcesContent":["/**\n * `@singi-labs/sifa-sdk/jev` -- TypeSafe System One (Jev) question builders,\n * taxonomies, result schemas, and threshold predicates for Sifa's organisation\n * intelligence.\n *\n * This subpath is pure: it defines the typed *questions* to ask Jev, validates\n * the *answers*, and holds the policy constants (thresholds, taxonomies) that\n * turn a raw judgment into a decision. It performs no network calls and needs\n * no API key, so it is safe to import anywhere. The actual `systemOne` call\n * lives server-side in sifa-api, which passes an API key that must never reach\n * the browser.\n *\n * Judgment shapes:\n * - Duplicate-org detection: a single Noul over a candidate pair.\n * - Firmographic classification: independent Choice questions (industry, type,\n * size) asked together over one org's state.\n *\n * See docs.typesafe.ai for the wire contract. Typed output guarantees the\n * interface, not truth: validate thresholds on labelled Sifa data before\n * enabling any write.\n */\n\nimport { z } from 'zod';\n\n// ---------------------------------------------------------------------------\n// Wire question types\n// ---------------------------------------------------------------------------\n\n/** A yes/no judgment. `criteria` clarifies what true vs false means. */\nexport interface NoulQuestion {\n type: 'noul';\n instructions: string;\n criteria?: { true: string; false: string };\n}\n\n/** A single-selection judgment. `criteria` maps each option to its description. */\nexport interface ChoiceQuestion {\n type: 'choice';\n instructions: string;\n criteria: Record<string, string>;\n}\n\n// ---------------------------------------------------------------------------\n// Answer schemas (validate Jev's response before code trusts it)\n// ---------------------------------------------------------------------------\n\n/** Jev's Noul answer: `noul` is the probability that the answer is yes (0..1). */\nexport const noulAnswerSchema = z.object({\n type: z.literal('noul'),\n noul: z.number().min(0).max(1),\n});\nexport type NoulAnswer = z.infer<typeof noulAnswerSchema>;\n\n/**\n * Jev's Choice answer: `choice` is the highest-probability option, `confidence`\n * summarises how concentrated the distribution is, `probabilities` sums to 1.\n */\nexport const choiceAnswerSchema = z.object({\n type: z.literal('choice'),\n choice: z.string(),\n confidence: z.number().min(0).max(1),\n probabilities: z.record(z.string(), z.number()),\n});\nexport type ChoiceAnswer = z.infer<typeof choiceAnswerSchema>;\n\n// ---------------------------------------------------------------------------\n// Duplicate-org detection (Noul) -- ticket #560\n// ---------------------------------------------------------------------------\n\n/** Public, non-user fields we compare when judging whether two orgs are the same. */\nexport interface OrgRecordInput {\n name: string;\n domain?: string;\n location?: string;\n description?: string;\n}\n\n/** Build a plain object with only the defined keys (Jev sees no `undefined`). */\nfunction compact<T extends object>(obj: T): Partial<T> {\n const out: Partial<T> = {};\n for (const [k, v] of Object.entries(obj)) {\n if (v !== undefined && v !== null && v !== '') out[k as keyof T] = v as T[keyof T];\n }\n return out;\n}\n\n/**\n * State for the org-pair judgment. Both records live under stable named fields\n * so the question can reference them unambiguously.\n */\nexport function buildOrgPairState(\n a: OrgRecordInput,\n b: OrgRecordInput,\n): { orgA: Partial<OrgRecordInput>; orgB: Partial<OrgRecordInput> } {\n return { orgA: compact(a), orgB: compact(b) };\n}\n\n/** Static Noul question paired with {@link buildOrgPairState}. */\nexport const ORG_PAIR_NOUL_QUESTION: NoulQuestion = {\n type: 'noul',\n instructions:\n 'The state holds two organisation records, orgA and orgB. Do they refer to the same real-world organisation?',\n criteria: {\n true: 'Same organisation, e.g. a legal-suffix or abbreviation variant of the same name, or the same primary domain.',\n false:\n 'Distinct organisations, e.g. a shared word in the name but different domains, locations, or lines of business.',\n },\n};\n\n/**\n * Threshold favouring precision: a wrong merge is worse than a missed one.\n * Validate on labelled pairs (ticket #560) before enabling merges in prod.\n */\nexport const SAME_ORG_THRESHOLD = 0.85;\n\n/** True when the Noul probability clears the merge threshold. */\nexport function isLikelySameOrg(noul: number, threshold: number = SAME_ORG_THRESHOLD): boolean {\n return noul >= threshold;\n}\n\n// ---------------------------------------------------------------------------\n// Firmographic classification (Choice) -- ticket #561\n// ---------------------------------------------------------------------------\n\n/** Industry taxonomy. Single source of truth; `other` is the no-match option. */\nexport const INDUSTRY_TAXONOMY: Record<string, string> = {\n technology: 'Software, IT services, internet, hardware, telecoms',\n finance: 'Banking, insurance, investment, fintech, accounting',\n healthcare: 'Medical care, pharma, biotech, health services',\n education: 'Schools, universities, training, edtech',\n manufacturing: 'Industrial production, hardware, materials, automotive',\n retail: 'Retail, ecommerce, consumer goods, hospitality, food',\n media: 'Media, publishing, entertainment, marketing, advertising',\n energy: 'Energy, utilities, oil and gas, renewables',\n construction: 'Construction, real estate, architecture, engineering',\n transport: 'Transport, logistics, shipping, aviation',\n professional_services: 'Legal, consulting, HR, business services',\n public_sector: 'Government, defence, public administration',\n nonprofit: 'Charities, NGOs, foundations, associations',\n agriculture: 'Agriculture, farming, fishing, forestry',\n other: 'None of the above, or not enough information to decide',\n};\n\n/**\n * Organisation type (the org's FORM, not its sector -- a hospital's type is\n * healthcare_provider while its industry is healthcare). `other` is the no-match\n * option. Prefer the registry `legalForm` where it already encodes the form.\n */\nexport const ORG_TYPE_TAXONOMY: Record<string, string> = {\n company: 'For-profit company or corporation',\n cooperative: 'Member-owned co-operative (e.g. eG, SCOP, mutual, credit union)',\n nonprofit: 'Nonprofit, charity, or NGO',\n foundation: 'Grant-making or endowed foundation (e.g. Stiftung, fonds)',\n government: 'Government body, agency, or public-sector authority',\n educational: 'School, college, or university',\n research_institute: 'Independent research institute or laboratory',\n healthcare_provider: 'Hospital, clinic, or healthcare provider',\n professional_association: 'Professional association, trade body, or trade union',\n agency: 'Agency, consultancy, or professional-services firm',\n religious: 'Religious organisation',\n political: 'Political party or organisation',\n self_employed: 'Sole proprietor, freelancer, or self-employed individual',\n other: 'None of the above, or not enough information to decide',\n};\n\n/** Ordered employee-size bands; `unknown` is the no-match option. */\nexport const SIZE_BAND_TAXONOMY: Record<string, string> = {\n solo: '1 person',\n small: '2 to 10 people',\n medium: '11 to 50 people',\n large: '51 to 250 people',\n enterprise: 'More than 250 people',\n unknown: 'Not enough information to decide',\n};\n\nfunction choiceQuestion(instructions: string, criteria: Record<string, string>): ChoiceQuestion {\n return { type: 'choice', instructions, criteria };\n}\n\nexport const INDUSTRY_CHOICE_QUESTION = choiceQuestion(\n 'Based on the org state, which single industry best describes this organisation?',\n INDUSTRY_TAXONOMY,\n);\n\nexport const ORG_TYPE_CHOICE_QUESTION = choiceQuestion(\n 'Based on the org state, which single organisation type (its form, not its industry sector) best describes this organisation?',\n ORG_TYPE_TAXONOMY,\n);\n\nexport const SIZE_BAND_CHOICE_QUESTION = choiceQuestion(\n 'Based on the org state, which employee-size band best fits this organisation?',\n SIZE_BAND_TAXONOMY,\n);\n\n/**\n * Input for firmographic classification. `enrichmentText` (the org's description)\n * is the strongest evidence, but it is optional: many registry orgs have only a\n * name, domain, country, or link set. Passing those lets Jev classify from the\n * signal we hold rather than skipping the org for want of a description. A bare\n * name alone rarely suffices; a name plus a domain or country often does.\n */\nexport interface FirmographicInput {\n name: string;\n domain?: string;\n country?: string;\n /** Featured/external link labels or hosts (e.g. 'linkedin', 'wikipedia'). */\n links?: string[];\n /** An already-known industry, when set (helps disambiguate type/size). */\n industry?: string;\n enrichmentText?: string;\n}\n\n/**\n * State for the firmographic questions. Drops absent/empty fields (including\n * empty arrays) so Jev never sees a null or `[]` it would have to reason about.\n */\nexport function buildFirmographicState(org: FirmographicInput): Partial<FirmographicInput> {\n const compacted = compact(org);\n if (compacted.links && compacted.links.length === 0) delete compacted.links;\n return compacted;\n}\n\n/**\n * Below this confidence a firmographic field is left unset rather than guessed.\n * Validate per field on labelled orgs (ticket #561).\n */\nexport const FIRMOGRAPHIC_CONFIDENCE_FLOOR = 0.6;\n\n/**\n * Select the classified option, or `null` to leave the field unset. Returns\n * null when confidence is below the floor, or when the selected option is the\n * taxonomy's no-match option.\n */\nexport function pickChoiceAboveFloor(\n answer: ChoiceAnswer,\n opts: { floor?: number; noMatch?: string } = {},\n): string | null {\n const floor = opts.floor ?? FIRMOGRAPHIC_CONFIDENCE_FLOOR;\n if (answer.confidence < floor) return null;\n if (opts.noMatch !== undefined && answer.choice === opts.noMatch) return null;\n return answer.choice;\n}\n"],"mappings":";AAsBA,SAAS,SAAS;AAyBX,IAAM,mBAAmB,EAAE,OAAO;AAAA,EACvC,MAAM,EAAE,QAAQ,MAAM;AAAA,EACtB,MAAM,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,IAAI,CAAC;AAC/B,CAAC;AAOM,IAAM,qBAAqB,EAAE,OAAO;AAAA,EACzC,MAAM,EAAE,QAAQ,QAAQ;AAAA,EACxB,QAAQ,EAAE,OAAO;AAAA,EACjB,YAAY,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,IAAI,CAAC;AAAA,EACnC,eAAe,EAAE,OAAO,EAAE,OAAO,GAAG,EAAE,OAAO,CAAC;AAChD,CAAC;AAgBD,SAAS,QAA0B,KAAoB;AACrD,QAAM,MAAkB,CAAC;AACzB,aAAW,CAAC,GAAG,CAAC,KAAK,OAAO,QAAQ,GAAG,GAAG;AACxC,QAAI,MAAM,UAAa,MAAM,QAAQ,MAAM,GAAI,KAAI,CAAY,IAAI;AAAA,EACrE;AACA,SAAO;AACT;AAMO,SAAS,kBACd,GACA,GACkE;AAClE,SAAO,EAAE,MAAM,QAAQ,CAAC,GAAG,MAAM,QAAQ,CAAC,EAAE;AAC9C;AAGO,IAAM,yBAAuC;AAAA,EAClD,MAAM;AAAA,EACN,cACE;AAAA,EACF,UAAU;AAAA,IACR,MAAM;AAAA,IACN,OACE;AAAA,EACJ;AACF;AAMO,IAAM,qBAAqB;AAG3B,SAAS,gBAAgB,MAAc,YAAoB,oBAA6B;AAC7F,SAAO,QAAQ;AACjB;AAOO,IAAM,oBAA4C;AAAA,EACvD,YAAY;AAAA,EACZ,SAAS;AAAA,EACT,YAAY;AAAA,EACZ,WAAW;AAAA,EACX,eAAe;AAAA,EACf,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,cAAc;AAAA,EACd,WAAW;AAAA,EACX,uBAAuB;AAAA,EACvB,eAAe;AAAA,EACf,WAAW;AAAA,EACX,aAAa;AAAA,EACb,OAAO;AACT;AAOO,IAAM,oBAA4C;AAAA,EACvD,SAAS;AAAA,EACT,aAAa;AAAA,EACb,WAAW;AAAA,EACX,YAAY;AAAA,EACZ,YAAY;AAAA,EACZ,aAAa;AAAA,EACb,oBAAoB;AAAA,EACpB,qBAAqB;AAAA,EACrB,0BAA0B;AAAA,EAC1B,QAAQ;AAAA,EACR,WAAW;AAAA,EACX,WAAW;AAAA,EACX,eAAe;AAAA,EACf,OAAO;AACT;AAGO,IAAM,qBAA6C;AAAA,EACxD,MAAM;AAAA,EACN,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,YAAY;AAAA,EACZ,SAAS;AACX;AAEA,SAAS,eAAe,cAAsB,UAAkD;AAC9F,SAAO,EAAE,MAAM,UAAU,cAAc,SAAS;AAClD;AAEO,IAAM,2BAA2B;AAAA,EACtC;AAAA,EACA;AACF;AAEO,IAAM,2BAA2B;AAAA,EACtC;AAAA,EACA;AACF;AAEO,IAAM,4BAA4B;AAAA,EACvC;AAAA,EACA;AACF;AAwBO,SAAS,uBAAuB,KAAoD;AACzF,QAAM,YAAY,QAAQ,GAAG;AAC7B,MAAI,UAAU,SAAS,UAAU,MAAM,WAAW,EAAG,QAAO,UAAU;AACtE,SAAO;AACT;AAMO,IAAM,gCAAgC;AAOtC,SAAS,qBACd,QACA,OAA6C,CAAC,GAC/B;AACf,QAAM,QAAQ,KAAK,SAAS;AAC5B,MAAI,OAAO,aAAa,MAAO,QAAO;AACtC,MAAI,KAAK,YAAY,UAAa,OAAO,WAAW,KAAK,QAAS,QAAO;AACzE,SAAO,OAAO;AAChB;","names":[]}
1
+ {"version":3,"sources":["../../src/jev/index.ts"],"sourcesContent":["/**\n * `@singi-labs/sifa-sdk/jev` -- TypeSafe System One (Jev) question builders,\n * taxonomies, result schemas, and threshold predicates for Sifa's organisation\n * intelligence.\n *\n * This subpath is pure: it defines the typed *questions* to ask Jev, validates\n * the *answers*, and holds the policy constants (thresholds, taxonomies) that\n * turn a raw judgment into a decision. It performs no network calls and needs\n * no API key, so it is safe to import anywhere. The actual `systemOne` call\n * lives server-side in sifa-api, which passes an API key that must never reach\n * the browser.\n *\n * Judgment shapes:\n * - Duplicate-org detection: a single Noul over a candidate pair.\n * - Firmographic classification: independent Choice questions (industry, type,\n * size) asked together over one org's state.\n *\n * See docs.typesafe.ai for the wire contract. Typed output guarantees the\n * interface, not truth: validate thresholds on labelled Sifa data before\n * enabling any write.\n */\n\nimport { z } from 'zod';\n\n// ---------------------------------------------------------------------------\n// Wire question types\n// ---------------------------------------------------------------------------\n\n/** A yes/no judgment. `criteria` clarifies what true vs false means. */\nexport interface NoulQuestion {\n type: 'noul';\n instructions: string;\n criteria?: { true: string; false: string };\n}\n\n/** A single-selection judgment. `criteria` maps each option to its description. */\nexport interface ChoiceQuestion {\n type: 'choice';\n instructions: string;\n criteria: Record<string, string>;\n}\n\n// ---------------------------------------------------------------------------\n// Answer schemas (validate Jev's response before code trusts it)\n// ---------------------------------------------------------------------------\n\n/** Jev's Noul answer: `noul` is the probability that the answer is yes (0..1). */\nexport const noulAnswerSchema = z.object({\n type: z.literal('noul'),\n noul: z.number().min(0).max(1),\n});\nexport type NoulAnswer = z.infer<typeof noulAnswerSchema>;\n\n/**\n * Jev's Choice answer: `choice` is the highest-probability option, `confidence`\n * summarises how concentrated the distribution is, `probabilities` sums to 1.\n */\nexport const choiceAnswerSchema = z.object({\n type: z.literal('choice'),\n choice: z.string(),\n confidence: z.number().min(0).max(1),\n probabilities: z.record(z.string(), z.number()),\n});\nexport type ChoiceAnswer = z.infer<typeof choiceAnswerSchema>;\n\n// ---------------------------------------------------------------------------\n// Duplicate-org detection (Noul) -- ticket #560\n// ---------------------------------------------------------------------------\n\n/** Public, non-user fields we compare when judging whether two orgs are the same. */\nexport interface OrgRecordInput {\n name: string;\n domain?: string;\n location?: string;\n description?: string;\n}\n\n/** Build a plain object with only the defined keys (Jev sees no `undefined`). */\nfunction compact<T extends object>(obj: T): Partial<T> {\n const out: Partial<T> = {};\n for (const [k, v] of Object.entries(obj)) {\n if (v !== undefined && v !== null && v !== '') out[k as keyof T] = v as T[keyof T];\n }\n return out;\n}\n\n/**\n * State for the org-pair judgment. Both records live under stable named fields\n * so the question can reference them unambiguously.\n */\nexport function buildOrgPairState(\n a: OrgRecordInput,\n b: OrgRecordInput,\n): { orgA: Partial<OrgRecordInput>; orgB: Partial<OrgRecordInput> } {\n return { orgA: compact(a), orgB: compact(b) };\n}\n\n/** Static Noul question paired with {@link buildOrgPairState}. */\nexport const ORG_PAIR_NOUL_QUESTION: NoulQuestion = {\n type: 'noul',\n instructions:\n 'The state holds two organisation records, orgA and orgB. Do they refer to the same real-world organisation?',\n criteria: {\n true: 'Same organisation, e.g. a legal-suffix or abbreviation variant of the same name, or the same primary domain.',\n false:\n 'Distinct organisations, e.g. a shared word in the name but different domains, locations, or lines of business.',\n },\n};\n\n/**\n * Threshold favouring precision: a wrong merge is worse than a missed one.\n * Validate on labelled pairs (ticket #560) before enabling merges in prod.\n */\nexport const SAME_ORG_THRESHOLD = 0.85;\n\n/** True when the Noul probability clears the merge threshold. */\nexport function isLikelySameOrg(noul: number, threshold: number = SAME_ORG_THRESHOLD): boolean {\n return noul >= threshold;\n}\n\n// ---------------------------------------------------------------------------\n// Firmographic classification (Choice) -- ticket #561\n// ---------------------------------------------------------------------------\n\n/** Industry taxonomy. Single source of truth; `other` is the no-match option. */\nexport const INDUSTRY_TAXONOMY: Record<string, string> = {\n technology: 'Software, IT services, internet, hardware, telecoms',\n finance: 'Banking, insurance, investment, fintech, accounting',\n healthcare: 'Medical care, pharma, biotech, health services',\n education: 'Schools, universities, training, edtech',\n manufacturing: 'Industrial production, hardware, materials, automotive',\n retail: 'Retail, ecommerce, consumer goods, hospitality, food',\n media: 'Media, publishing, entertainment, marketing, advertising',\n energy: 'Energy, utilities, oil and gas, renewables',\n construction: 'Construction, real estate, architecture, engineering',\n transport: 'Transport, logistics, shipping, aviation',\n professional_services: 'Legal, consulting, HR, business services',\n public_sector: 'Government, defence, public administration',\n nonprofit: 'Charities, NGOs, foundations, associations',\n agriculture: 'Agriculture, farming, fishing, forestry',\n other: 'None of the above, or not enough information to decide',\n};\n\n/**\n * Organisation type (the org's FORM, not its sector -- a hospital's type is\n * healthcare_provider while its industry is healthcare). `other` is the no-match\n * option. Prefer the registry `legalForm` where it already encodes the form.\n */\nexport const ORG_TYPE_TAXONOMY: Record<string, string> = {\n company: 'For-profit company or corporation',\n cooperative: 'Member-owned co-operative (e.g. eG, SCOP, mutual, credit union)',\n nonprofit: 'Nonprofit, charity, or NGO',\n foundation: 'Grant-making or endowed foundation (e.g. Stiftung, fonds)',\n government: 'Government body, agency, or public-sector authority',\n educational: 'School, college, or university',\n research_institute: 'Independent research institute or laboratory',\n healthcare_provider: 'Hospital, clinic, or healthcare provider',\n professional_association: 'Professional association, trade body, or trade union',\n agency: 'Agency, consultancy, or professional-services firm',\n religious: 'Religious organisation',\n political: 'Political party or organisation',\n self_employed: 'Sole proprietor, freelancer, or self-employed individual',\n other: 'None of the above, or not enough information to decide',\n};\n\n/**\n * Org types that also imply a sector. Jev can pick one that contradicts the\n * org's industry (a software company typed `healthcare_provider`), especially\n * from thin, description-less input. Each maps to lowercase substrings, one of\n * which the org's industry must contain for the inferred type to be shown.\n * Industry values are free text (Jev tokens like `public_sector`, or registry\n * labels like \"Hospital & Health Care\"), hence substring hints. Types not listed\n * here describe a legal or organisational form only and need no sector match.\n */\nexport const SECTOR_ORG_TYPE_INDUSTRY_HINTS: Record<string, readonly string[]> = {\n healthcare_provider: ['health', 'hospital', 'medical', 'clinic', 'nursing', 'dental'],\n educational: ['educat', 'school', 'universit', 'college', 'academ'],\n government: ['government', 'public_sector', 'public sector', 'public administration', 'defen'],\n research_institute: ['research', 'scien', 'laborator', 'educat'],\n religious: ['relig', 'church', 'faith', 'nonprofit'],\n political: ['politic', 'nonprofit'],\n};\n\n/**\n * Whether a Jev-inferred org type is plausible enough to store or show.\n *\n * - The no-match option and unknown tokens are never plausible.\n * - Form types (company, nonprofit, ...) are always plausible.\n * - Sector-bound types need an org description (thin input is where Jev\n * guesses wrong) AND an industry that matches the type's sector.\n */\nexport function isInferredOrgTypePlausible(\n orgType: string,\n ctx: { industry?: string | null; hasDescription: boolean },\n): boolean {\n if (orgType === 'other' || !Object.hasOwn(ORG_TYPE_TAXONOMY, orgType)) return false;\n const hints = SECTOR_ORG_TYPE_INDUSTRY_HINTS[orgType];\n if (!hints) return true;\n if (!ctx.hasDescription || !ctx.industry) return false;\n const industry = ctx.industry.toLowerCase();\n return hints.some((hint) => industry.includes(hint));\n}\n\n/**\n * Jev-inferred company facts a signed-in visitor can dispute (mark as wrong).\n * A dispute hides the inferred value; it never touches registry or owner data.\n */\nexport const INFERRED_FACT_FIELDS = ['industry', 'sizeBand', 'orgType'] as const;\nexport type InferredFactField = (typeof INFERRED_FACT_FIELDS)[number];\n\n/** Ordered employee-size bands; `unknown` is the no-match option. */\nexport const SIZE_BAND_TAXONOMY: Record<string, string> = {\n solo: '1 person',\n small: '2 to 10 people',\n medium: '11 to 50 people',\n large: '51 to 250 people',\n enterprise: 'More than 250 people',\n unknown: 'Not enough information to decide',\n};\n\nfunction choiceQuestion(instructions: string, criteria: Record<string, string>): ChoiceQuestion {\n return { type: 'choice', instructions, criteria };\n}\n\nexport const INDUSTRY_CHOICE_QUESTION = choiceQuestion(\n 'Based on the org state, which single industry best describes this organisation?',\n INDUSTRY_TAXONOMY,\n);\n\nexport const ORG_TYPE_CHOICE_QUESTION = choiceQuestion(\n 'Based on the org state, which single organisation type (its form, not its industry sector) best describes this organisation?',\n ORG_TYPE_TAXONOMY,\n);\n\nexport const SIZE_BAND_CHOICE_QUESTION = choiceQuestion(\n 'Based on the org state, which employee-size band best fits this organisation?',\n SIZE_BAND_TAXONOMY,\n);\n\n/**\n * Input for firmographic classification. `enrichmentText` (the org's description)\n * is the strongest evidence, but it is optional: many registry orgs have only a\n * name, domain, country, or link set. Passing those lets Jev classify from the\n * signal we hold rather than skipping the org for want of a description. A bare\n * name alone rarely suffices; a name plus a domain or country often does.\n */\nexport interface FirmographicInput {\n name: string;\n domain?: string;\n country?: string;\n /** Featured/external link labels or hosts (e.g. 'linkedin', 'wikipedia'). */\n links?: string[];\n /** An already-known industry, when set (helps disambiguate type/size). */\n industry?: string;\n enrichmentText?: string;\n}\n\n/**\n * State for the firmographic questions. Drops absent/empty fields (including\n * empty arrays) so Jev never sees a null or `[]` it would have to reason about.\n */\nexport function buildFirmographicState(org: FirmographicInput): Partial<FirmographicInput> {\n const compacted = compact(org);\n if (compacted.links && compacted.links.length === 0) delete compacted.links;\n return compacted;\n}\n\n/**\n * Below this confidence a firmographic field is left unset rather than guessed.\n * Validate per field on labelled orgs (ticket #561).\n */\nexport const FIRMOGRAPHIC_CONFIDENCE_FLOOR = 0.6;\n\n/**\n * Select the classified option, or `null` to leave the field unset. Returns\n * null when confidence is below the floor, or when the selected option is the\n * taxonomy's no-match option.\n */\nexport function pickChoiceAboveFloor(\n answer: ChoiceAnswer,\n opts: { floor?: number; noMatch?: string } = {},\n): string | null {\n const floor = opts.floor ?? FIRMOGRAPHIC_CONFIDENCE_FLOOR;\n if (answer.confidence < floor) return null;\n if (opts.noMatch !== undefined && answer.choice === opts.noMatch) return null;\n return answer.choice;\n}\n"],"mappings":"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","names":[]}
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/jev/index.ts","../../src/jev/issue.ts"],"names":["z","choiceQuestion"],"mappings":"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","file":"issue.cjs","sourcesContent":["/**\n * `@singi-labs/sifa-sdk/jev` -- TypeSafe System One (Jev) question builders,\n * taxonomies, result schemas, and threshold predicates for Sifa's organisation\n * intelligence.\n *\n * This subpath is pure: it defines the typed *questions* to ask Jev, validates\n * the *answers*, and holds the policy constants (thresholds, taxonomies) that\n * turn a raw judgment into a decision. It performs no network calls and needs\n * no API key, so it is safe to import anywhere. The actual `systemOne` call\n * lives server-side in sifa-api, which passes an API key that must never reach\n * the browser.\n *\n * Judgment shapes:\n * - Duplicate-org detection: a single Noul over a candidate pair.\n * - Firmographic classification: independent Choice questions (industry, type,\n * size) asked together over one org's state.\n *\n * See docs.typesafe.ai for the wire contract. Typed output guarantees the\n * interface, not truth: validate thresholds on labelled Sifa data before\n * enabling any write.\n */\n\nimport { z } from 'zod';\n\n// ---------------------------------------------------------------------------\n// Wire question types\n// ---------------------------------------------------------------------------\n\n/** A yes/no judgment. `criteria` clarifies what true vs false means. */\nexport interface NoulQuestion {\n type: 'noul';\n instructions: string;\n criteria?: { true: string; false: string };\n}\n\n/** A single-selection judgment. `criteria` maps each option to its description. */\nexport interface ChoiceQuestion {\n type: 'choice';\n instructions: string;\n criteria: Record<string, string>;\n}\n\n// ---------------------------------------------------------------------------\n// Answer schemas (validate Jev's response before code trusts it)\n// ---------------------------------------------------------------------------\n\n/** Jev's Noul answer: `noul` is the probability that the answer is yes (0..1). */\nexport const noulAnswerSchema = z.object({\n type: z.literal('noul'),\n noul: z.number().min(0).max(1),\n});\nexport type NoulAnswer = z.infer<typeof noulAnswerSchema>;\n\n/**\n * Jev's Choice answer: `choice` is the highest-probability option, `confidence`\n * summarises how concentrated the distribution is, `probabilities` sums to 1.\n */\nexport const choiceAnswerSchema = z.object({\n type: z.literal('choice'),\n choice: z.string(),\n confidence: z.number().min(0).max(1),\n probabilities: z.record(z.string(), z.number()),\n});\nexport type ChoiceAnswer = z.infer<typeof choiceAnswerSchema>;\n\n// ---------------------------------------------------------------------------\n// Duplicate-org detection (Noul) -- ticket #560\n// ---------------------------------------------------------------------------\n\n/** Public, non-user fields we compare when judging whether two orgs are the same. */\nexport interface OrgRecordInput {\n name: string;\n domain?: string;\n location?: string;\n description?: string;\n}\n\n/** Build a plain object with only the defined keys (Jev sees no `undefined`). */\nfunction compact<T extends object>(obj: T): Partial<T> {\n const out: Partial<T> = {};\n for (const [k, v] of Object.entries(obj)) {\n if (v !== undefined && v !== null && v !== '') out[k as keyof T] = v as T[keyof T];\n }\n return out;\n}\n\n/**\n * State for the org-pair judgment. Both records live under stable named fields\n * so the question can reference them unambiguously.\n */\nexport function buildOrgPairState(\n a: OrgRecordInput,\n b: OrgRecordInput,\n): { orgA: Partial<OrgRecordInput>; orgB: Partial<OrgRecordInput> } {\n return { orgA: compact(a), orgB: compact(b) };\n}\n\n/** Static Noul question paired with {@link buildOrgPairState}. */\nexport const ORG_PAIR_NOUL_QUESTION: NoulQuestion = {\n type: 'noul',\n instructions:\n 'The state holds two organisation records, orgA and orgB. Do they refer to the same real-world organisation?',\n criteria: {\n true: 'Same organisation, e.g. a legal-suffix or abbreviation variant of the same name, or the same primary domain.',\n false:\n 'Distinct organisations, e.g. a shared word in the name but different domains, locations, or lines of business.',\n },\n};\n\n/**\n * Threshold favouring precision: a wrong merge is worse than a missed one.\n * Validate on labelled pairs (ticket #560) before enabling merges in prod.\n */\nexport const SAME_ORG_THRESHOLD = 0.85;\n\n/** True when the Noul probability clears the merge threshold. */\nexport function isLikelySameOrg(noul: number, threshold: number = SAME_ORG_THRESHOLD): boolean {\n return noul >= threshold;\n}\n\n// ---------------------------------------------------------------------------\n// Firmographic classification (Choice) -- ticket #561\n// ---------------------------------------------------------------------------\n\n/** Industry taxonomy. Single source of truth; `other` is the no-match option. */\nexport const INDUSTRY_TAXONOMY: Record<string, string> = {\n technology: 'Software, IT services, internet, hardware, telecoms',\n finance: 'Banking, insurance, investment, fintech, accounting',\n healthcare: 'Medical care, pharma, biotech, health services',\n education: 'Schools, universities, training, edtech',\n manufacturing: 'Industrial production, hardware, materials, automotive',\n retail: 'Retail, ecommerce, consumer goods, hospitality, food',\n media: 'Media, publishing, entertainment, marketing, advertising',\n energy: 'Energy, utilities, oil and gas, renewables',\n construction: 'Construction, real estate, architecture, engineering',\n transport: 'Transport, logistics, shipping, aviation',\n professional_services: 'Legal, consulting, HR, business services',\n public_sector: 'Government, defence, public administration',\n nonprofit: 'Charities, NGOs, foundations, associations',\n agriculture: 'Agriculture, farming, fishing, forestry',\n other: 'None of the above, or not enough information to decide',\n};\n\n/**\n * Organisation type (the org's FORM, not its sector -- a hospital's type is\n * healthcare_provider while its industry is healthcare). `other` is the no-match\n * option. Prefer the registry `legalForm` where it already encodes the form.\n */\nexport const ORG_TYPE_TAXONOMY: Record<string, string> = {\n company: 'For-profit company or corporation',\n cooperative: 'Member-owned co-operative (e.g. eG, SCOP, mutual, credit union)',\n nonprofit: 'Nonprofit, charity, or NGO',\n foundation: 'Grant-making or endowed foundation (e.g. Stiftung, fonds)',\n government: 'Government body, agency, or public-sector authority',\n educational: 'School, college, or university',\n research_institute: 'Independent research institute or laboratory',\n healthcare_provider: 'Hospital, clinic, or healthcare provider',\n professional_association: 'Professional association, trade body, or trade union',\n agency: 'Agency, consultancy, or professional-services firm',\n religious: 'Religious organisation',\n political: 'Political party or organisation',\n self_employed: 'Sole proprietor, freelancer, or self-employed individual',\n other: 'None of the above, or not enough information to decide',\n};\n\n/** Ordered employee-size bands; `unknown` is the no-match option. */\nexport const SIZE_BAND_TAXONOMY: Record<string, string> = {\n solo: '1 person',\n small: '2 to 10 people',\n medium: '11 to 50 people',\n large: '51 to 250 people',\n enterprise: 'More than 250 people',\n unknown: 'Not enough information to decide',\n};\n\nfunction choiceQuestion(instructions: string, criteria: Record<string, string>): ChoiceQuestion {\n return { type: 'choice', instructions, criteria };\n}\n\nexport const INDUSTRY_CHOICE_QUESTION = choiceQuestion(\n 'Based on the org state, which single industry best describes this organisation?',\n INDUSTRY_TAXONOMY,\n);\n\nexport const ORG_TYPE_CHOICE_QUESTION = choiceQuestion(\n 'Based on the org state, which single organisation type (its form, not its industry sector) best describes this organisation?',\n ORG_TYPE_TAXONOMY,\n);\n\nexport const SIZE_BAND_CHOICE_QUESTION = choiceQuestion(\n 'Based on the org state, which employee-size band best fits this organisation?',\n SIZE_BAND_TAXONOMY,\n);\n\n/**\n * Input for firmographic classification. `enrichmentText` (the org's description)\n * is the strongest evidence, but it is optional: many registry orgs have only a\n * name, domain, country, or link set. Passing those lets Jev classify from the\n * signal we hold rather than skipping the org for want of a description. A bare\n * name alone rarely suffices; a name plus a domain or country often does.\n */\nexport interface FirmographicInput {\n name: string;\n domain?: string;\n country?: string;\n /** Featured/external link labels or hosts (e.g. 'linkedin', 'wikipedia'). */\n links?: string[];\n /** An already-known industry, when set (helps disambiguate type/size). */\n industry?: string;\n enrichmentText?: string;\n}\n\n/**\n * State for the firmographic questions. Drops absent/empty fields (including\n * empty arrays) so Jev never sees a null or `[]` it would have to reason about.\n */\nexport function buildFirmographicState(org: FirmographicInput): Partial<FirmographicInput> {\n const compacted = compact(org);\n if (compacted.links && compacted.links.length === 0) delete compacted.links;\n return compacted;\n}\n\n/**\n * Below this confidence a firmographic field is left unset rather than guessed.\n * Validate per field on labelled orgs (ticket #561).\n */\nexport const FIRMOGRAPHIC_CONFIDENCE_FLOOR = 0.6;\n\n/**\n * Select the classified option, or `null` to leave the field unset. Returns\n * null when confidence is below the floor, or when the selected option is the\n * taxonomy's no-match option.\n */\nexport function pickChoiceAboveFloor(\n answer: ChoiceAnswer,\n opts: { floor?: number; noMatch?: string } = {},\n): string | null {\n const floor = opts.floor ?? FIRMOGRAPHIC_CONFIDENCE_FLOOR;\n if (answer.confidence < floor) return null;\n if (opts.noMatch !== undefined && answer.choice === opts.noMatch) return null;\n return answer.choice;\n}\n","/**\n * Issue-triage judgments for the background-agent chores (sifa-workspace\n * decision 2026-10-05, background agents tier policy). Pure builders: the\n * typed questions to ask Jev about a GitHub issue, the label taxonomies, and\n * the thresholds that turn answers into label suggestions. No network, no key.\n *\n * Scope is deliberately T0 (read-only): the output is a list of label\n * suggestions a workflow may apply from an allowlist, never a code change.\n * Jev only sees public issue text. Exact signals (GlitchTip links, template\n * fields present) stay in code, not in a judgment.\n */\n\nimport { z } from 'zod';\n\nimport {\n choiceAnswerSchema,\n noulAnswerSchema,\n pickChoiceAboveFloor,\n type ChoiceAnswer,\n type ChoiceQuestion,\n type NoulAnswer,\n type NoulQuestion,\n} from './index.js';\n\n// ---------------------------------------------------------------------------\n// Input and state\n// ---------------------------------------------------------------------------\n\n/** Public, non-user fields of a GitHub issue that the judgment may see. */\nexport interface IssueInput {\n repo: string;\n title: string;\n body?: string;\n /** Labels already on the issue (e.g. set by a template). */\n existingLabels?: string[];\n}\n\n/** Longest issue body forwarded to Jev; keeps state bounded and cheap. */\nexport const ISSUE_BODY_MAX_CHARS = 6000;\n\nexport interface IssueTriageState {\n repo: string;\n title: string;\n body?: string;\n bodyTruncated?: true;\n existingLabels?: string[];\n}\n\n/** State for the issue questions. Absent and blank fields are dropped. */\nexport function buildIssueTriageState(issue: IssueInput): IssueTriageState {\n const state: IssueTriageState = { repo: issue.repo, title: issue.title.trim() };\n const body = issue.body?.trim();\n if (body) {\n if (body.length > ISSUE_BODY_MAX_CHARS) {\n state.body = body.slice(0, ISSUE_BODY_MAX_CHARS);\n state.bodyTruncated = true;\n } else {\n state.body = body;\n }\n }\n if (issue.existingLabels && issue.existingLabels.length > 0) {\n state.existingLabels = issue.existingLabels;\n }\n return state;\n}\n\n// ---------------------------------------------------------------------------\n// Taxonomies (keys double as label names where a label exists)\n// ---------------------------------------------------------------------------\n\n/** One type label per issue. `unclear` is the no-match option and never a label. */\nexport const ISSUE_TYPE_TAXONOMY: Record<string, string> = {\n bug: 'Something that exists does not behave as documented or expected (error, crash, wrong output)',\n feature: 'A request for new behaviour or capability that does not exist yet',\n question: 'A question about how something works, with nothing to build or fix',\n documentation: 'Docs, README, comments, or examples are missing, wrong, or unclear',\n 'tech-debt':\n 'Internal refactor, cleanup, dependency, or tooling work with no user-visible change',\n spam: 'Spam, gibberish, advertising, or a test submission',\n unclear: 'Not enough information to decide the type',\n};\n\n/** Which product stream an issue belongs to. `unclear` is the no-match option. */\nexport const ISSUE_STREAM_TAXONOMY: Record<string, string> = {\n sifa: 'The Sifa profile, activity stream, ingestion, search, or web app',\n kootana: 'Kootana: events, meetings, in-person connect, QR scan, organizer tools',\n unclear: 'Cannot tell from the issue',\n};\n\n/** Label applied for the Kootana stream; the Sifa stream carries no label. */\nexport const ISSUE_STREAM_LABELS: Readonly<Record<string, string | null>> = {\n sifa: null,\n kootana: 'stream:kootana',\n unclear: null,\n};\n\n// ---------------------------------------------------------------------------\n// Questions (asked together over one issue state)\n// ---------------------------------------------------------------------------\n\nfunction choiceQuestion(instructions: string, criteria: Record<string, string>): ChoiceQuestion {\n return { type: 'choice', instructions, criteria };\n}\n\nexport const ISSUE_TYPE_CHOICE_QUESTION = choiceQuestion(\n 'The state holds one GitHub issue (repo, title, body, existing labels). Which single type best describes it?',\n ISSUE_TYPE_TAXONOMY,\n);\n\nexport const ISSUE_STREAM_CHOICE_QUESTION = choiceQuestion(\n 'Which product stream does this issue concern?',\n ISSUE_STREAM_TAXONOMY,\n);\n\nexport const ISSUE_ACTIONABLE_NOUL_QUESTION: NoulQuestion = {\n type: 'noul',\n instructions:\n 'Does the issue contain enough information for a maintainer to act on it without asking the reporter anything?',\n criteria: {\n true: 'For a bug: steps or a trigger, expected vs actual, and an error or location. For a feature: the problem, the desired outcome, and rough scope.',\n false:\n 'Essential details are missing: no way to reproduce, no expected behaviour, vague wish with no outcome, or a bare title.',\n },\n};\n\nexport const ISSUE_SECURITY_NOUL_QUESTION: NoulQuestion = {\n type: 'noul',\n instructions: 'Does the issue describe a security-relevant problem?',\n criteria: {\n true: 'A vulnerability, auth or session flaw, data exposure, abuse or spam vector, secrets handling, or a dependency advisory.',\n false: 'An ordinary bug, feature, question, or docs issue with no security angle.',\n },\n};\n\nexport const ISSUE_REGRESSION_NOUL_QUESTION: NoulQuestion = {\n type: 'noul',\n instructions: 'Does the issue report that something which previously worked no longer works?',\n criteria: {\n true: 'The reporter states or clearly implies the behaviour worked before a release, deploy, or change.',\n false: 'A new bug with no prior-working claim, a feature request, or a question.',\n },\n};\n\n/** Stable question ids; the answer keys Jev returns. */\nexport const ISSUE_TRIAGE_QUESTION_IDS = {\n type: 'issueType',\n stream: 'issueStream',\n actionable: 'issueActionable',\n security: 'issueSecurity',\n regression: 'issueRegression',\n} as const;\n\n/** All issue questions, keyed by {@link ISSUE_TRIAGE_QUESTION_IDS}, for one batched call. */\nexport function buildIssueTriageQuestions(): Record<string, ChoiceQuestion | NoulQuestion> {\n return {\n [ISSUE_TRIAGE_QUESTION_IDS.type]: ISSUE_TYPE_CHOICE_QUESTION,\n [ISSUE_TRIAGE_QUESTION_IDS.stream]: ISSUE_STREAM_CHOICE_QUESTION,\n [ISSUE_TRIAGE_QUESTION_IDS.actionable]: ISSUE_ACTIONABLE_NOUL_QUESTION,\n [ISSUE_TRIAGE_QUESTION_IDS.security]: ISSUE_SECURITY_NOUL_QUESTION,\n [ISSUE_TRIAGE_QUESTION_IDS.regression]: ISSUE_REGRESSION_NOUL_QUESTION,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Answers -> label suggestions\n// ---------------------------------------------------------------------------\n\n/** Parsed answers for the batched issue call. */\nexport const issueTriageAnswersSchema = z.object({\n [ISSUE_TRIAGE_QUESTION_IDS.type]: choiceAnswerSchema,\n [ISSUE_TRIAGE_QUESTION_IDS.stream]: choiceAnswerSchema,\n [ISSUE_TRIAGE_QUESTION_IDS.actionable]: noulAnswerSchema,\n [ISSUE_TRIAGE_QUESTION_IDS.security]: noulAnswerSchema,\n [ISSUE_TRIAGE_QUESTION_IDS.regression]: noulAnswerSchema,\n});\nexport type IssueTriageAnswers = z.infer<typeof issueTriageAnswersSchema>;\n\n/**\n * Thresholds. Precision over recall: a wrong label costs a maintainer a click\n * and trains distrust; a missing label costs nothing. Validate on labelled\n * closed issues (sifa-api#1555) before widening.\n */\nexport const ISSUE_TYPE_CONFIDENCE_FLOOR = 0.6;\nexport const ISSUE_STREAM_CONFIDENCE_FLOOR = 0.7;\n/** Below this P(actionable) the issue gets `needs-info`. */\nexport const ISSUE_NEEDS_INFO_MAX_ACTIONABLE = 0.35;\n/** Routing labels (security, regression) need a confident yes. */\nexport const ISSUE_ROUTING_THRESHOLD = 0.85;\n\nexport interface IssueLabelSuggestions {\n /** Labels to apply, in a stable order, each guaranteed to be a real label name. */\n labels: string[];\n /** The type choice after the floor, or null when unset. */\n type: string | null;\n /** The stream choice after the floor, or null when unset. */\n stream: string | null;\n needsInfo: boolean;\n security: boolean;\n regression: boolean;\n}\n\nexport interface IssueLabelOptions {\n typeFloor?: number;\n streamFloor?: number;\n needsInfoMaxActionable?: number;\n routingThreshold?: number;\n}\n\nfunction clearsNoul(answer: NoulAnswer, threshold: number): boolean {\n return answer.noul >= threshold;\n}\n\nfunction pickType(answer: ChoiceAnswer, floor: number): string | null {\n const choice = pickChoiceAboveFloor(answer, { floor, noMatch: 'unclear' });\n return choice !== null && choice in ISSUE_TYPE_TAXONOMY ? choice : null;\n}\n\nfunction pickStream(answer: ChoiceAnswer, floor: number): string | null {\n const choice = pickChoiceAboveFloor(answer, { floor, noMatch: 'unclear' });\n return choice !== null && choice in ISSUE_STREAM_TAXONOMY ? choice : null;\n}\n\n/**\n * Turn validated answers into label suggestions. Spam short-circuits: a spam\n * issue gets only `spam`, no routing or needs-info. A choice outside the\n * taxonomy (Jev invented a key) is treated as unset.\n */\nexport function issueLabelsFromAnswers(\n answers: IssueTriageAnswers,\n opts: IssueLabelOptions = {},\n): IssueLabelSuggestions {\n const typeFloor = opts.typeFloor ?? ISSUE_TYPE_CONFIDENCE_FLOOR;\n const streamFloor = opts.streamFloor ?? ISSUE_STREAM_CONFIDENCE_FLOOR;\n const needsInfoMax = opts.needsInfoMaxActionable ?? ISSUE_NEEDS_INFO_MAX_ACTIONABLE;\n const routing = opts.routingThreshold ?? ISSUE_ROUTING_THRESHOLD;\n\n const type = pickType(answers[ISSUE_TRIAGE_QUESTION_IDS.type], typeFloor);\n if (type === 'spam') {\n return {\n labels: ['spam'],\n type,\n stream: null,\n needsInfo: false,\n security: false,\n regression: false,\n };\n }\n\n const stream = pickStream(answers[ISSUE_TRIAGE_QUESTION_IDS.stream], streamFloor);\n const needsInfo = answers[ISSUE_TRIAGE_QUESTION_IDS.actionable].noul < needsInfoMax;\n const security = clearsNoul(answers[ISSUE_TRIAGE_QUESTION_IDS.security], routing);\n const regression = clearsNoul(answers[ISSUE_TRIAGE_QUESTION_IDS.regression], routing);\n\n const labels: string[] = [];\n if (type !== null) labels.push(type);\n if (security) labels.push('security');\n if (regression) labels.push('regression');\n const streamLabel = stream !== null ? ISSUE_STREAM_LABELS[stream] : null;\n if (streamLabel) labels.push(streamLabel);\n if (needsInfo) labels.push('needs-info');\n\n return { labels, type, stream, needsInfo, security, regression };\n}\n"]}
1
+ {"version":3,"sources":["../../src/jev/index.ts","../../src/jev/issue.ts"],"names":["z","choiceQuestion"],"mappings":"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","file":"issue.cjs","sourcesContent":["/**\n * `@singi-labs/sifa-sdk/jev` -- TypeSafe System One (Jev) question builders,\n * taxonomies, result schemas, and threshold predicates for Sifa's organisation\n * intelligence.\n *\n * This subpath is pure: it defines the typed *questions* to ask Jev, validates\n * the *answers*, and holds the policy constants (thresholds, taxonomies) that\n * turn a raw judgment into a decision. It performs no network calls and needs\n * no API key, so it is safe to import anywhere. The actual `systemOne` call\n * lives server-side in sifa-api, which passes an API key that must never reach\n * the browser.\n *\n * Judgment shapes:\n * - Duplicate-org detection: a single Noul over a candidate pair.\n * - Firmographic classification: independent Choice questions (industry, type,\n * size) asked together over one org's state.\n *\n * See docs.typesafe.ai for the wire contract. Typed output guarantees the\n * interface, not truth: validate thresholds on labelled Sifa data before\n * enabling any write.\n */\n\nimport { z } from 'zod';\n\n// ---------------------------------------------------------------------------\n// Wire question types\n// ---------------------------------------------------------------------------\n\n/** A yes/no judgment. `criteria` clarifies what true vs false means. */\nexport interface NoulQuestion {\n type: 'noul';\n instructions: string;\n criteria?: { true: string; false: string };\n}\n\n/** A single-selection judgment. `criteria` maps each option to its description. */\nexport interface ChoiceQuestion {\n type: 'choice';\n instructions: string;\n criteria: Record<string, string>;\n}\n\n// ---------------------------------------------------------------------------\n// Answer schemas (validate Jev's response before code trusts it)\n// ---------------------------------------------------------------------------\n\n/** Jev's Noul answer: `noul` is the probability that the answer is yes (0..1). */\nexport const noulAnswerSchema = z.object({\n type: z.literal('noul'),\n noul: z.number().min(0).max(1),\n});\nexport type NoulAnswer = z.infer<typeof noulAnswerSchema>;\n\n/**\n * Jev's Choice answer: `choice` is the highest-probability option, `confidence`\n * summarises how concentrated the distribution is, `probabilities` sums to 1.\n */\nexport const choiceAnswerSchema = z.object({\n type: z.literal('choice'),\n choice: z.string(),\n confidence: z.number().min(0).max(1),\n probabilities: z.record(z.string(), z.number()),\n});\nexport type ChoiceAnswer = z.infer<typeof choiceAnswerSchema>;\n\n// ---------------------------------------------------------------------------\n// Duplicate-org detection (Noul) -- ticket #560\n// ---------------------------------------------------------------------------\n\n/** Public, non-user fields we compare when judging whether two orgs are the same. */\nexport interface OrgRecordInput {\n name: string;\n domain?: string;\n location?: string;\n description?: string;\n}\n\n/** Build a plain object with only the defined keys (Jev sees no `undefined`). */\nfunction compact<T extends object>(obj: T): Partial<T> {\n const out: Partial<T> = {};\n for (const [k, v] of Object.entries(obj)) {\n if (v !== undefined && v !== null && v !== '') out[k as keyof T] = v as T[keyof T];\n }\n return out;\n}\n\n/**\n * State for the org-pair judgment. Both records live under stable named fields\n * so the question can reference them unambiguously.\n */\nexport function buildOrgPairState(\n a: OrgRecordInput,\n b: OrgRecordInput,\n): { orgA: Partial<OrgRecordInput>; orgB: Partial<OrgRecordInput> } {\n return { orgA: compact(a), orgB: compact(b) };\n}\n\n/** Static Noul question paired with {@link buildOrgPairState}. */\nexport const ORG_PAIR_NOUL_QUESTION: NoulQuestion = {\n type: 'noul',\n instructions:\n 'The state holds two organisation records, orgA and orgB. Do they refer to the same real-world organisation?',\n criteria: {\n true: 'Same organisation, e.g. a legal-suffix or abbreviation variant of the same name, or the same primary domain.',\n false:\n 'Distinct organisations, e.g. a shared word in the name but different domains, locations, or lines of business.',\n },\n};\n\n/**\n * Threshold favouring precision: a wrong merge is worse than a missed one.\n * Validate on labelled pairs (ticket #560) before enabling merges in prod.\n */\nexport const SAME_ORG_THRESHOLD = 0.85;\n\n/** True when the Noul probability clears the merge threshold. */\nexport function isLikelySameOrg(noul: number, threshold: number = SAME_ORG_THRESHOLD): boolean {\n return noul >= threshold;\n}\n\n// ---------------------------------------------------------------------------\n// Firmographic classification (Choice) -- ticket #561\n// ---------------------------------------------------------------------------\n\n/** Industry taxonomy. Single source of truth; `other` is the no-match option. */\nexport const INDUSTRY_TAXONOMY: Record<string, string> = {\n technology: 'Software, IT services, internet, hardware, telecoms',\n finance: 'Banking, insurance, investment, fintech, accounting',\n healthcare: 'Medical care, pharma, biotech, health services',\n education: 'Schools, universities, training, edtech',\n manufacturing: 'Industrial production, hardware, materials, automotive',\n retail: 'Retail, ecommerce, consumer goods, hospitality, food',\n media: 'Media, publishing, entertainment, marketing, advertising',\n energy: 'Energy, utilities, oil and gas, renewables',\n construction: 'Construction, real estate, architecture, engineering',\n transport: 'Transport, logistics, shipping, aviation',\n professional_services: 'Legal, consulting, HR, business services',\n public_sector: 'Government, defence, public administration',\n nonprofit: 'Charities, NGOs, foundations, associations',\n agriculture: 'Agriculture, farming, fishing, forestry',\n other: 'None of the above, or not enough information to decide',\n};\n\n/**\n * Organisation type (the org's FORM, not its sector -- a hospital's type is\n * healthcare_provider while its industry is healthcare). `other` is the no-match\n * option. Prefer the registry `legalForm` where it already encodes the form.\n */\nexport const ORG_TYPE_TAXONOMY: Record<string, string> = {\n company: 'For-profit company or corporation',\n cooperative: 'Member-owned co-operative (e.g. eG, SCOP, mutual, credit union)',\n nonprofit: 'Nonprofit, charity, or NGO',\n foundation: 'Grant-making or endowed foundation (e.g. Stiftung, fonds)',\n government: 'Government body, agency, or public-sector authority',\n educational: 'School, college, or university',\n research_institute: 'Independent research institute or laboratory',\n healthcare_provider: 'Hospital, clinic, or healthcare provider',\n professional_association: 'Professional association, trade body, or trade union',\n agency: 'Agency, consultancy, or professional-services firm',\n religious: 'Religious organisation',\n political: 'Political party or organisation',\n self_employed: 'Sole proprietor, freelancer, or self-employed individual',\n other: 'None of the above, or not enough information to decide',\n};\n\n/**\n * Org types that also imply a sector. Jev can pick one that contradicts the\n * org's industry (a software company typed `healthcare_provider`), especially\n * from thin, description-less input. Each maps to lowercase substrings, one of\n * which the org's industry must contain for the inferred type to be shown.\n * Industry values are free text (Jev tokens like `public_sector`, or registry\n * labels like \"Hospital & Health Care\"), hence substring hints. Types not listed\n * here describe a legal or organisational form only and need no sector match.\n */\nexport const SECTOR_ORG_TYPE_INDUSTRY_HINTS: Record<string, readonly string[]> = {\n healthcare_provider: ['health', 'hospital', 'medical', 'clinic', 'nursing', 'dental'],\n educational: ['educat', 'school', 'universit', 'college', 'academ'],\n government: ['government', 'public_sector', 'public sector', 'public administration', 'defen'],\n research_institute: ['research', 'scien', 'laborator', 'educat'],\n religious: ['relig', 'church', 'faith', 'nonprofit'],\n political: ['politic', 'nonprofit'],\n};\n\n/**\n * Whether a Jev-inferred org type is plausible enough to store or show.\n *\n * - The no-match option and unknown tokens are never plausible.\n * - Form types (company, nonprofit, ...) are always plausible.\n * - Sector-bound types need an org description (thin input is where Jev\n * guesses wrong) AND an industry that matches the type's sector.\n */\nexport function isInferredOrgTypePlausible(\n orgType: string,\n ctx: { industry?: string | null; hasDescription: boolean },\n): boolean {\n if (orgType === 'other' || !Object.hasOwn(ORG_TYPE_TAXONOMY, orgType)) return false;\n const hints = SECTOR_ORG_TYPE_INDUSTRY_HINTS[orgType];\n if (!hints) return true;\n if (!ctx.hasDescription || !ctx.industry) return false;\n const industry = ctx.industry.toLowerCase();\n return hints.some((hint) => industry.includes(hint));\n}\n\n/**\n * Jev-inferred company facts a signed-in visitor can dispute (mark as wrong).\n * A dispute hides the inferred value; it never touches registry or owner data.\n */\nexport const INFERRED_FACT_FIELDS = ['industry', 'sizeBand', 'orgType'] as const;\nexport type InferredFactField = (typeof INFERRED_FACT_FIELDS)[number];\n\n/** Ordered employee-size bands; `unknown` is the no-match option. */\nexport const SIZE_BAND_TAXONOMY: Record<string, string> = {\n solo: '1 person',\n small: '2 to 10 people',\n medium: '11 to 50 people',\n large: '51 to 250 people',\n enterprise: 'More than 250 people',\n unknown: 'Not enough information to decide',\n};\n\nfunction choiceQuestion(instructions: string, criteria: Record<string, string>): ChoiceQuestion {\n return { type: 'choice', instructions, criteria };\n}\n\nexport const INDUSTRY_CHOICE_QUESTION = choiceQuestion(\n 'Based on the org state, which single industry best describes this organisation?',\n INDUSTRY_TAXONOMY,\n);\n\nexport const ORG_TYPE_CHOICE_QUESTION = choiceQuestion(\n 'Based on the org state, which single organisation type (its form, not its industry sector) best describes this organisation?',\n ORG_TYPE_TAXONOMY,\n);\n\nexport const SIZE_BAND_CHOICE_QUESTION = choiceQuestion(\n 'Based on the org state, which employee-size band best fits this organisation?',\n SIZE_BAND_TAXONOMY,\n);\n\n/**\n * Input for firmographic classification. `enrichmentText` (the org's description)\n * is the strongest evidence, but it is optional: many registry orgs have only a\n * name, domain, country, or link set. Passing those lets Jev classify from the\n * signal we hold rather than skipping the org for want of a description. A bare\n * name alone rarely suffices; a name plus a domain or country often does.\n */\nexport interface FirmographicInput {\n name: string;\n domain?: string;\n country?: string;\n /** Featured/external link labels or hosts (e.g. 'linkedin', 'wikipedia'). */\n links?: string[];\n /** An already-known industry, when set (helps disambiguate type/size). */\n industry?: string;\n enrichmentText?: string;\n}\n\n/**\n * State for the firmographic questions. Drops absent/empty fields (including\n * empty arrays) so Jev never sees a null or `[]` it would have to reason about.\n */\nexport function buildFirmographicState(org: FirmographicInput): Partial<FirmographicInput> {\n const compacted = compact(org);\n if (compacted.links && compacted.links.length === 0) delete compacted.links;\n return compacted;\n}\n\n/**\n * Below this confidence a firmographic field is left unset rather than guessed.\n * Validate per field on labelled orgs (ticket #561).\n */\nexport const FIRMOGRAPHIC_CONFIDENCE_FLOOR = 0.6;\n\n/**\n * Select the classified option, or `null` to leave the field unset. Returns\n * null when confidence is below the floor, or when the selected option is the\n * taxonomy's no-match option.\n */\nexport function pickChoiceAboveFloor(\n answer: ChoiceAnswer,\n opts: { floor?: number; noMatch?: string } = {},\n): string | null {\n const floor = opts.floor ?? FIRMOGRAPHIC_CONFIDENCE_FLOOR;\n if (answer.confidence < floor) return null;\n if (opts.noMatch !== undefined && answer.choice === opts.noMatch) return null;\n return answer.choice;\n}\n","/**\n * Issue-triage judgments for the background-agent chores (sifa-workspace\n * decision 2026-10-05, background agents tier policy). Pure builders: the\n * typed questions to ask Jev about a GitHub issue, the label taxonomies, and\n * the thresholds that turn answers into label suggestions. No network, no key.\n *\n * Scope is deliberately T0 (read-only): the output is a list of label\n * suggestions a workflow may apply from an allowlist, never a code change.\n * Jev only sees public issue text. Exact signals (GlitchTip links, template\n * fields present) stay in code, not in a judgment.\n */\n\nimport { z } from 'zod';\n\nimport {\n choiceAnswerSchema,\n noulAnswerSchema,\n pickChoiceAboveFloor,\n type ChoiceAnswer,\n type ChoiceQuestion,\n type NoulAnswer,\n type NoulQuestion,\n} from './index.js';\n\n// ---------------------------------------------------------------------------\n// Input and state\n// ---------------------------------------------------------------------------\n\n/** Public, non-user fields of a GitHub issue that the judgment may see. */\nexport interface IssueInput {\n repo: string;\n title: string;\n body?: string;\n /** Labels already on the issue (e.g. set by a template). */\n existingLabels?: string[];\n}\n\n/** Longest issue body forwarded to Jev; keeps state bounded and cheap. */\nexport const ISSUE_BODY_MAX_CHARS = 6000;\n\nexport interface IssueTriageState {\n repo: string;\n title: string;\n body?: string;\n bodyTruncated?: true;\n existingLabels?: string[];\n}\n\n/** State for the issue questions. Absent and blank fields are dropped. */\nexport function buildIssueTriageState(issue: IssueInput): IssueTriageState {\n const state: IssueTriageState = { repo: issue.repo, title: issue.title.trim() };\n const body = issue.body?.trim();\n if (body) {\n if (body.length > ISSUE_BODY_MAX_CHARS) {\n state.body = body.slice(0, ISSUE_BODY_MAX_CHARS);\n state.bodyTruncated = true;\n } else {\n state.body = body;\n }\n }\n if (issue.existingLabels && issue.existingLabels.length > 0) {\n state.existingLabels = issue.existingLabels;\n }\n return state;\n}\n\n// ---------------------------------------------------------------------------\n// Taxonomies (keys double as label names where a label exists)\n// ---------------------------------------------------------------------------\n\n/** One type label per issue. `unclear` is the no-match option and never a label. */\nexport const ISSUE_TYPE_TAXONOMY: Record<string, string> = {\n bug: 'Something that exists does not behave as documented or expected (error, crash, wrong output)',\n feature: 'A request for new behaviour or capability that does not exist yet',\n question: 'A question about how something works, with nothing to build or fix',\n documentation: 'Docs, README, comments, or examples are missing, wrong, or unclear',\n 'tech-debt':\n 'Internal refactor, cleanup, dependency, or tooling work with no user-visible change',\n spam: 'Spam, gibberish, advertising, or a test submission',\n unclear: 'Not enough information to decide the type',\n};\n\n/** Which product stream an issue belongs to. `unclear` is the no-match option. */\nexport const ISSUE_STREAM_TAXONOMY: Record<string, string> = {\n sifa: 'The Sifa profile, activity stream, ingestion, search, or web app',\n kootana: 'Kootana: events, meetings, in-person connect, QR scan, organizer tools',\n unclear: 'Cannot tell from the issue',\n};\n\n/** Label applied for the Kootana stream; the Sifa stream carries no label. */\nexport const ISSUE_STREAM_LABELS: Readonly<Record<string, string | null>> = {\n sifa: null,\n kootana: 'stream:kootana',\n unclear: null,\n};\n\n// ---------------------------------------------------------------------------\n// Questions (asked together over one issue state)\n// ---------------------------------------------------------------------------\n\nfunction choiceQuestion(instructions: string, criteria: Record<string, string>): ChoiceQuestion {\n return { type: 'choice', instructions, criteria };\n}\n\nexport const ISSUE_TYPE_CHOICE_QUESTION = choiceQuestion(\n 'The state holds one GitHub issue (repo, title, body, existing labels). Which single type best describes it?',\n ISSUE_TYPE_TAXONOMY,\n);\n\nexport const ISSUE_STREAM_CHOICE_QUESTION = choiceQuestion(\n 'Which product stream does this issue concern?',\n ISSUE_STREAM_TAXONOMY,\n);\n\nexport const ISSUE_ACTIONABLE_NOUL_QUESTION: NoulQuestion = {\n type: 'noul',\n instructions:\n 'Does the issue contain enough information for a maintainer to act on it without asking the reporter anything?',\n criteria: {\n true: 'For a bug: steps or a trigger, expected vs actual, and an error or location. For a feature: the problem, the desired outcome, and rough scope.',\n false:\n 'Essential details are missing: no way to reproduce, no expected behaviour, vague wish with no outcome, or a bare title.',\n },\n};\n\nexport const ISSUE_SECURITY_NOUL_QUESTION: NoulQuestion = {\n type: 'noul',\n instructions: 'Does the issue describe a security-relevant problem?',\n criteria: {\n true: 'A vulnerability, auth or session flaw, data exposure, abuse or spam vector, secrets handling, or a dependency advisory.',\n false: 'An ordinary bug, feature, question, or docs issue with no security angle.',\n },\n};\n\nexport const ISSUE_REGRESSION_NOUL_QUESTION: NoulQuestion = {\n type: 'noul',\n instructions: 'Does the issue report that something which previously worked no longer works?',\n criteria: {\n true: 'The reporter states or clearly implies the behaviour worked before a release, deploy, or change.',\n false: 'A new bug with no prior-working claim, a feature request, or a question.',\n },\n};\n\n/** Stable question ids; the answer keys Jev returns. */\nexport const ISSUE_TRIAGE_QUESTION_IDS = {\n type: 'issueType',\n stream: 'issueStream',\n actionable: 'issueActionable',\n security: 'issueSecurity',\n regression: 'issueRegression',\n} as const;\n\n/** All issue questions, keyed by {@link ISSUE_TRIAGE_QUESTION_IDS}, for one batched call. */\nexport function buildIssueTriageQuestions(): Record<string, ChoiceQuestion | NoulQuestion> {\n return {\n [ISSUE_TRIAGE_QUESTION_IDS.type]: ISSUE_TYPE_CHOICE_QUESTION,\n [ISSUE_TRIAGE_QUESTION_IDS.stream]: ISSUE_STREAM_CHOICE_QUESTION,\n [ISSUE_TRIAGE_QUESTION_IDS.actionable]: ISSUE_ACTIONABLE_NOUL_QUESTION,\n [ISSUE_TRIAGE_QUESTION_IDS.security]: ISSUE_SECURITY_NOUL_QUESTION,\n [ISSUE_TRIAGE_QUESTION_IDS.regression]: ISSUE_REGRESSION_NOUL_QUESTION,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Answers -> label suggestions\n// ---------------------------------------------------------------------------\n\n/** Parsed answers for the batched issue call. */\nexport const issueTriageAnswersSchema = z.object({\n [ISSUE_TRIAGE_QUESTION_IDS.type]: choiceAnswerSchema,\n [ISSUE_TRIAGE_QUESTION_IDS.stream]: choiceAnswerSchema,\n [ISSUE_TRIAGE_QUESTION_IDS.actionable]: noulAnswerSchema,\n [ISSUE_TRIAGE_QUESTION_IDS.security]: noulAnswerSchema,\n [ISSUE_TRIAGE_QUESTION_IDS.regression]: noulAnswerSchema,\n});\nexport type IssueTriageAnswers = z.infer<typeof issueTriageAnswersSchema>;\n\n/**\n * Thresholds. Precision over recall: a wrong label costs a maintainer a click\n * and trains distrust; a missing label costs nothing. Validate on labelled\n * closed issues (sifa-api#1555) before widening.\n */\nexport const ISSUE_TYPE_CONFIDENCE_FLOOR = 0.6;\nexport const ISSUE_STREAM_CONFIDENCE_FLOOR = 0.7;\n/** Below this P(actionable) the issue gets `needs-info`. */\nexport const ISSUE_NEEDS_INFO_MAX_ACTIONABLE = 0.35;\n/** Routing labels (security, regression) need a confident yes. */\nexport const ISSUE_ROUTING_THRESHOLD = 0.85;\n\nexport interface IssueLabelSuggestions {\n /** Labels to apply, in a stable order, each guaranteed to be a real label name. */\n labels: string[];\n /** The type choice after the floor, or null when unset. */\n type: string | null;\n /** The stream choice after the floor, or null when unset. */\n stream: string | null;\n needsInfo: boolean;\n security: boolean;\n regression: boolean;\n}\n\nexport interface IssueLabelOptions {\n typeFloor?: number;\n streamFloor?: number;\n needsInfoMaxActionable?: number;\n routingThreshold?: number;\n}\n\nfunction clearsNoul(answer: NoulAnswer, threshold: number): boolean {\n return answer.noul >= threshold;\n}\n\nfunction pickType(answer: ChoiceAnswer, floor: number): string | null {\n const choice = pickChoiceAboveFloor(answer, { floor, noMatch: 'unclear' });\n return choice !== null && choice in ISSUE_TYPE_TAXONOMY ? choice : null;\n}\n\nfunction pickStream(answer: ChoiceAnswer, floor: number): string | null {\n const choice = pickChoiceAboveFloor(answer, { floor, noMatch: 'unclear' });\n return choice !== null && choice in ISSUE_STREAM_TAXONOMY ? choice : null;\n}\n\n/**\n * Turn validated answers into label suggestions. Spam short-circuits: a spam\n * issue gets only `spam`, no routing or needs-info. A choice outside the\n * taxonomy (Jev invented a key) is treated as unset.\n */\nexport function issueLabelsFromAnswers(\n answers: IssueTriageAnswers,\n opts: IssueLabelOptions = {},\n): IssueLabelSuggestions {\n const typeFloor = opts.typeFloor ?? ISSUE_TYPE_CONFIDENCE_FLOOR;\n const streamFloor = opts.streamFloor ?? ISSUE_STREAM_CONFIDENCE_FLOOR;\n const needsInfoMax = opts.needsInfoMaxActionable ?? ISSUE_NEEDS_INFO_MAX_ACTIONABLE;\n const routing = opts.routingThreshold ?? ISSUE_ROUTING_THRESHOLD;\n\n const type = pickType(answers[ISSUE_TRIAGE_QUESTION_IDS.type], typeFloor);\n if (type === 'spam') {\n return {\n labels: ['spam'],\n type,\n stream: null,\n needsInfo: false,\n security: false,\n regression: false,\n };\n }\n\n const stream = pickStream(answers[ISSUE_TRIAGE_QUESTION_IDS.stream], streamFloor);\n const needsInfo = answers[ISSUE_TRIAGE_QUESTION_IDS.actionable].noul < needsInfoMax;\n const security = clearsNoul(answers[ISSUE_TRIAGE_QUESTION_IDS.security], routing);\n const regression = clearsNoul(answers[ISSUE_TRIAGE_QUESTION_IDS.regression], routing);\n\n const labels: string[] = [];\n if (type !== null) labels.push(type);\n if (security) labels.push('security');\n if (regression) labels.push('regression');\n const streamLabel = stream !== null ? ISSUE_STREAM_LABELS[stream] : null;\n if (streamLabel) labels.push(streamLabel);\n if (needsInfo) labels.push('needs-info');\n\n return { labels, type, stream, needsInfo, security, regression };\n}\n"]}
@@ -13,14 +13,16 @@ async function getAdminReviewQueues(config, opts = {}) {
13
13
  const nameCorrections = res.nameCorrections ?? 0;
14
14
  const pendingCompanies = res.pendingCompanies ?? 0;
15
15
  const duplicateOrgs = res.duplicateOrgs ?? 0;
16
+ const inferredFactReports = res.inferredFactReports ?? 0;
16
17
  return {
17
18
  ideas,
18
19
  nameCorrections,
19
20
  pendingCompanies,
20
21
  duplicateOrgs,
22
+ inferredFactReports,
21
23
  // Derived rather than trusted so an API that predates the field still
22
24
  // yields a usable pill count.
23
- total: res.total ?? ideas + nameCorrections + pendingCompanies + duplicateOrgs,
25
+ total: res.total ?? ideas + nameCorrections + pendingCompanies + duplicateOrgs + inferredFactReports,
24
26
  generatedAt: res.generatedAt ?? ""
25
27
  };
26
28
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/query/fetchers/admin-review-queues.ts"],"sourcesContent":["import { apiFetch, type ApiFetchOptions, type SifaApiConfig } from '../client.js';\n\n/**\n * Options for {@link getAdminReviewQueues}. Supports the same `cookieHeader`\n * passthrough as the other admin reads for RSC contexts.\n */\nexport interface GetAdminReviewQueuesOptions extends ApiFetchOptions {\n cookieHeader?: string;\n}\n\n/** Response shape of `GET /api/admin/stats/review-queues`. */\nexport interface AdminReviewQueues {\n ideas: number;\n nameCorrections: number;\n pendingCompanies: number;\n duplicateOrgs: number;\n /** Sum of the queues. */\n total: number;\n generatedAt: string;\n}\n\n/**\n * Open counts for the three admin review queues (ideas, pending companies,\n * name corrections) plus their total.\n *\n * Unlike {@link listFeatureAllowlist} this does NOT swallow errors: a zeroed\n * payload would render as \"all queues clear\" in the admin nav, which is worse\n * than rendering nothing. Callers decide how to degrade.\n */\nexport async function getAdminReviewQueues(\n config: SifaApiConfig,\n opts: GetAdminReviewQueuesOptions = {},\n): Promise<AdminReviewQueues> {\n const headers: Record<string, string> = { ...(opts.headers ?? {}) };\n if (opts.cookieHeader) headers.cookie = opts.cookieHeader;\n\n const res = await apiFetch<Partial<AdminReviewQueues>>(config, '/api/admin/stats/review-queues', {\n credentials: 'include',\n cache: 'no-store',\n ...opts,\n headers,\n });\n\n const ideas = res.ideas ?? 0;\n const nameCorrections = res.nameCorrections ?? 0;\n const pendingCompanies = res.pendingCompanies ?? 0;\n const duplicateOrgs = res.duplicateOrgs ?? 0;\n\n return {\n ideas,\n nameCorrections,\n pendingCompanies,\n duplicateOrgs,\n // Derived rather than trusted so an API that predates the field still\n // yields a usable pill count.\n total: res.total ?? ideas + nameCorrections + pendingCompanies + duplicateOrgs,\n generatedAt: res.generatedAt ?? '',\n };\n}\n"],"mappings":";AAAA,SAAS,gBAA0D;AA6BnE,eAAsB,qBACpB,QACA,OAAoC,CAAC,GACT;AAC5B,QAAM,UAAkC,EAAE,GAAI,KAAK,WAAW,CAAC,EAAG;AAClE,MAAI,KAAK,aAAc,SAAQ,SAAS,KAAK;AAE7C,QAAM,MAAM,MAAM,SAAqC,QAAQ,kCAAkC;AAAA,IAC/F,aAAa;AAAA,IACb,OAAO;AAAA,IACP,GAAG;AAAA,IACH;AAAA,EACF,CAAC;AAED,QAAM,QAAQ,IAAI,SAAS;AAC3B,QAAM,kBAAkB,IAAI,mBAAmB;AAC/C,QAAM,mBAAmB,IAAI,oBAAoB;AACjD,QAAM,gBAAgB,IAAI,iBAAiB;AAE3C,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA;AAAA;AAAA,IAGA,OAAO,IAAI,SAAS,QAAQ,kBAAkB,mBAAmB;AAAA,IACjE,aAAa,IAAI,eAAe;AAAA,EAClC;AACF;","names":[]}
1
+ {"version":3,"sources":["../../../src/query/fetchers/admin-review-queues.ts"],"sourcesContent":["import { apiFetch, type ApiFetchOptions, type SifaApiConfig } from '../client.js';\n\n/**\n * Options for {@link getAdminReviewQueues}. Supports the same `cookieHeader`\n * passthrough as the other admin reads for RSC contexts.\n */\nexport interface GetAdminReviewQueuesOptions extends ApiFetchOptions {\n cookieHeader?: string;\n}\n\n/** Response shape of `GET /api/admin/stats/review-queues`. */\nexport interface AdminReviewQueues {\n ideas: number;\n nameCorrections: number;\n pendingCompanies: number;\n duplicateOrgs: number;\n /** Pending reports of wrong Jev-inferred company facts (one per org + field). */\n inferredFactReports: number;\n /** Sum of the queues. */\n total: number;\n generatedAt: string;\n}\n\n/**\n * Open counts for the three admin review queues (ideas, pending companies,\n * name corrections) plus their total.\n *\n * Unlike {@link listFeatureAllowlist} this does NOT swallow errors: a zeroed\n * payload would render as \"all queues clear\" in the admin nav, which is worse\n * than rendering nothing. Callers decide how to degrade.\n */\nexport async function getAdminReviewQueues(\n config: SifaApiConfig,\n opts: GetAdminReviewQueuesOptions = {},\n): Promise<AdminReviewQueues> {\n const headers: Record<string, string> = { ...(opts.headers ?? {}) };\n if (opts.cookieHeader) headers.cookie = opts.cookieHeader;\n\n const res = await apiFetch<Partial<AdminReviewQueues>>(config, '/api/admin/stats/review-queues', {\n credentials: 'include',\n cache: 'no-store',\n ...opts,\n headers,\n });\n\n const ideas = res.ideas ?? 0;\n const nameCorrections = res.nameCorrections ?? 0;\n const pendingCompanies = res.pendingCompanies ?? 0;\n const duplicateOrgs = res.duplicateOrgs ?? 0;\n const inferredFactReports = res.inferredFactReports ?? 0;\n\n return {\n ideas,\n nameCorrections,\n pendingCompanies,\n duplicateOrgs,\n inferredFactReports,\n // Derived rather than trusted so an API that predates the field still\n // yields a usable pill count.\n total:\n res.total ?? ideas + nameCorrections + pendingCompanies + duplicateOrgs + inferredFactReports,\n generatedAt: res.generatedAt ?? '',\n };\n}\n"],"mappings":";AAAA,SAAS,gBAA0D;AA+BnE,eAAsB,qBACpB,QACA,OAAoC,CAAC,GACT;AAC5B,QAAM,UAAkC,EAAE,GAAI,KAAK,WAAW,CAAC,EAAG;AAClE,MAAI,KAAK,aAAc,SAAQ,SAAS,KAAK;AAE7C,QAAM,MAAM,MAAM,SAAqC,QAAQ,kCAAkC;AAAA,IAC/F,aAAa;AAAA,IACb,OAAO;AAAA,IACP,GAAG;AAAA,IACH;AAAA,EACF,CAAC;AAED,QAAM,QAAQ,IAAI,SAAS;AAC3B,QAAM,kBAAkB,IAAI,mBAAmB;AAC/C,QAAM,mBAAmB,IAAI,oBAAoB;AACjD,QAAM,gBAAgB,IAAI,iBAAiB;AAC3C,QAAM,sBAAsB,IAAI,uBAAuB;AAEvD,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA;AAAA;AAAA,IAGA,OACE,IAAI,SAAS,QAAQ,kBAAkB,mBAAmB,gBAAgB;AAAA,IAC5E,aAAa,IAAI,eAAe;AAAA,EAClC;AACF;","names":[]}
@@ -631,6 +631,9 @@ var PROFILE_COMPLETENESS_SIGNALS = [
631
631
  "skill",
632
632
  "certification"
633
633
  ];
634
+ function isProfileCompletenessSignal(value) {
635
+ return PROFILE_COMPLETENESS_SIGNALS.some((signal) => signal === value);
636
+ }
634
637
  async function fetchProfileCompleteness(config, options = {}) {
635
638
  const { cookieHeader, ...rest } = options;
636
639
  const headers = { ...rest.headers ?? {} };
@@ -651,16 +654,27 @@ async function fetchProfileCompleteness(config, options = {}) {
651
654
  });
652
655
  if (!data) return empty;
653
656
  const missing = Array.isArray(data.missing) ? data.missing : [];
657
+ const dismissed = Array.isArray(data.dismissed) ? data.dismissed.filter(isProfileCompletenessSignal) : void 0;
654
658
  return {
655
659
  complete: data.complete ?? missing.length === 0,
656
660
  score: data.score ?? PROFILE_COMPLETENESS_SIGNALS.length - missing.length,
657
661
  total: data.total ?? PROFILE_COMPLETENESS_SIGNALS.length,
658
- missing
662
+ missing,
663
+ ...dismissed ? { dismissed } : {}
659
664
  };
660
665
  } catch {
661
666
  return empty;
662
667
  }
663
668
  }
669
+ function dismissCompletenessSignal(config, data, options = {}) {
670
+ return apiWrite(config, "/api/profile/completeness/dismiss", "POST", { body: data, ...options });
671
+ }
672
+ function clearCompletenessDismissals(config, options = {}) {
673
+ return apiWrite(config, "/api/profile/completeness/dismissals/clear", "POST", {
674
+ body: {},
675
+ ...options
676
+ });
677
+ }
664
678
 
665
679
  // src/query/fetchers/keytrace-claims.ts
666
680
  function hideKeytraceClaim(config, rkey, options = {}) {
@@ -1270,14 +1284,16 @@ async function getAdminReviewQueues(config, opts = {}) {
1270
1284
  const nameCorrections = res.nameCorrections ?? 0;
1271
1285
  const pendingCompanies = res.pendingCompanies ?? 0;
1272
1286
  const duplicateOrgs = res.duplicateOrgs ?? 0;
1287
+ const inferredFactReports = res.inferredFactReports ?? 0;
1273
1288
  return {
1274
1289
  ideas,
1275
1290
  nameCorrections,
1276
1291
  pendingCompanies,
1277
1292
  duplicateOrgs,
1293
+ inferredFactReports,
1278
1294
  // Derived rather than trusted so an API that predates the field still
1279
1295
  // yields a usable pill count.
1280
- total: res.total ?? ideas + nameCorrections + pendingCompanies + duplicateOrgs,
1296
+ total: res.total ?? ideas + nameCorrections + pendingCompanies + duplicateOrgs + inferredFactReports,
1281
1297
  generatedAt: res.generatedAt ?? ""
1282
1298
  };
1283
1299
  }
@@ -2267,6 +2283,7 @@ exports.bulkUnhideStandardPublications = bulkUnhideStandardPublications;
2267
2283
  exports.castRoadmapVote = castRoadmapVote;
2268
2284
  exports.checkAppAccount = checkAppAccount;
2269
2285
  exports.checkNetworkMapJobStatus = checkNetworkMapJobStatus;
2286
+ exports.clearCompletenessDismissals = clearCompletenessDismissals;
2270
2287
  exports.confirmEndorsement = confirmEndorsement;
2271
2288
  exports.createConfirmation = createConfirmation;
2272
2289
  exports.createEducation = createEducation;
@@ -2290,6 +2307,7 @@ exports.deleteReaction = deleteReaction;
2290
2307
  exports.deleteRecord = deleteRecord;
2291
2308
  exports.deleteRepoRecords = deleteRepoRecords;
2292
2309
  exports.deleteSkill = deleteSkill;
2310
+ exports.dismissCompletenessSignal = dismissCompletenessSignal;
2293
2311
  exports.dismissConfirmation = dismissConfirmation;
2294
2312
  exports.dismissEndorsement = dismissEndorsement;
2295
2313
  exports.dismissUnlinkedPosition = dismissUnlinkedPosition;