| import { createHash } from "node:crypto"; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import type { PooledTweet } from "./tweet.js"; |
| import type { LabelConfig } from "./enrichment.js"; |
|
|
| |
| |
| |
| |
| export const PROCESSOR_VERSION = 1; |
|
|
| |
| |
| |
| |
| |
| export type SemanticTweetFields = { |
| id: string; |
| text: string; |
| conversation_id: string | undefined; |
| author_id: string | undefined; |
| author_username: string; |
| reply_to: string | undefined; |
| quoted_status_id: string | undefined; |
| expanded_urls: readonly string[]; |
| is_subscriber_only: boolean; |
| is_retweet: boolean; |
| }; |
|
|
| function firstStringField(tweet: PooledTweet, keys: readonly string[]): string | undefined { |
| for (const key of keys) { |
| const value = tweet[key]; |
| if (typeof value === "string" && value.length > 0) return value; |
| if (typeof value === "number") return String(value); |
| } |
| return undefined; |
| } |
|
|
| function firstBooleanField(tweet: PooledTweet, keys: readonly string[]): boolean { |
| for (const key of keys) { |
| const value = tweet[key]; |
| if (value === true) return true; |
| } |
| return false; |
| } |
|
|
| function collectStringArray(value: unknown, into: Set<string>): void { |
| if (!Array.isArray(value)) return; |
| for (const entry of value) { |
| if (typeof entry === "string" && entry.length > 0) into.add(entry); |
| } |
| } |
|
|
| function collectEntityUrls(entities: unknown, into: Set<string>): void { |
| if (typeof entities !== "object" || entities === null) return; |
| const urls = (entities as Record<string, unknown>)["urls"]; |
| if (!Array.isArray(urls)) return; |
| for (const entry of urls) addEntityUrl(entry, into); |
| } |
|
|
| function addEntityUrl(entry: unknown, into: Set<string>): void { |
| if (typeof entry !== "object" || entry === null) return; |
| const value = (entry as Record<string, unknown>)["expanded_url"]; |
| if (typeof value === "string" && value.length > 0) into.add(value); |
| } |
|
|
| function expandedUrls(tweet: PooledTweet): string[] { |
| const collected = new Set<string>(); |
| collectStringArray(tweet["expanded_urls"], collected); |
| collectEntityUrls(tweet["entities"], collected); |
| return [...collected].sort(); |
| } |
|
|
| |
| export function semanticTweetFields(tweet: PooledTweet): SemanticTweetFields { |
| return { |
| id: tweet.id, |
| text: tweet.text, |
| conversation_id: firstStringField(tweet, ["conversation_id"]), |
| author_id: typeof tweet.author.id === "string" ? tweet.author.id : undefined, |
| author_username: tweet.author.username.toLowerCase(), |
| reply_to: firstStringField(tweet, [ |
| "in_reply_to_status_id", |
| "in_reply_to_tweet_id", |
| "reply_to_status_id", |
| ]), |
| quoted_status_id: firstStringField(tweet, [ |
| "quoted_status_id", |
| "quoted_tweet_id", |
| "quote_status_id", |
| ]), |
| expanded_urls: expandedUrls(tweet), |
| is_subscriber_only: firstBooleanField(tweet, ["is_subscriber_only"]), |
| is_retweet: firstBooleanField(tweet, ["is_retweet"]), |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| export function canonicalJson(value: unknown): string { |
| if (value === null || typeof value !== "object") return JSON.stringify(value); |
| if (Array.isArray(value)) return `[${value.map((entry) => canonicalJson(entry)).join(",")}]`; |
| const entries = Object.entries(value as Record<string, unknown>) |
| .filter(([, entry]) => entry !== undefined) |
| .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); |
| return `{${entries |
| .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`) |
| .join(",")}}`; |
| } |
|
|
| function sha256Hex(value: string): string { |
| return createHash("sha256").update(value).digest("hex"); |
| } |
|
|
| |
| |
| |
| |
| export function computeInputHash(unitId: string, members: readonly SemanticTweetFields[]): string { |
| const sorted = [...members].sort((left, right) => |
| left.id < right.id ? -1 : left.id > right.id ? 1 : 0, |
| ); |
| return sha256Hex(canonicalJson({ unit_id: unitId, members: sorted })); |
| } |
|
|
| |
| export function computeInputHashFromTweets(unitId: string, tweets: readonly PooledTweet[]): string { |
| return computeInputHash( |
| unitId, |
| tweets.map((tweet) => semanticTweetFields(tweet)), |
| ); |
| } |
|
|
| |
| export type ContractInput = { |
| taxonomy_version: number; |
| labels: readonly LabelConfig[]; |
| model: string; |
| processor_version: number; |
| prompt_template_id: string; |
| output_schema_id: string; |
| normalization_id: string; |
| }; |
|
|
| |
| |
| |
| |
| export function computeContractHash(contract: ContractInput): string { |
| const canonical = { |
| taxonomy_version: contract.taxonomy_version, |
| model: contract.model, |
| processor_version: contract.processor_version, |
| prompt_template_id: contract.prompt_template_id, |
| output_schema_id: contract.output_schema_id, |
| normalization_id: contract.normalization_id, |
| labels: [...contract.labels] |
| .map((label) => ({ name: label.name, description: label.description })) |
| .sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0)), |
| }; |
| return sha256Hex(canonicalJson(canonical)); |
| } |
|
|