-
Notifications
You must be signed in to change notification settings - Fork 37
Expand file tree
/
Copy pathrunner_utils.ts
More file actions
395 lines (367 loc) · 13.4 KB
/
Copy pathrunner_utils.ts
File metadata and controls
395 lines (367 loc) · 13.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
// Copyright 2024 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// This code processes data from the `bin/` directory ingest scripts. In general, the shape
// takes the form of the `CoreCommentCsvRow` structure below, together with the vote tally
// columns of the form <Group Name>-agree-count, <Group Name>-disagree-count, and
// <Group Name>-pass-count.
import { Sensemaker } from "../src/sensemaker";
import { VertexModel } from "../src/models/vertex_model";
import {
Summary,
VoteTally,
Comment,
SummarizationType,
Topic,
SummaryContent,
VoteInfo,
} from "../src/types";
import * as path from "path";
import * as fs from "fs";
import { parse } from "csv-parse";
import { marked } from "marked";
import { createObjectCsvWriter } from "csv-writer";
/**
* Core comment columns, sans any vote tally rows
*/
type CoreCommentCsvRow = {
index: number;
timestamp: number;
datetime: string;
"comment-id": number;
"author-id": number;
agrees: number;
disagrees: number;
moderated: number;
comment_text: string;
passes: number;
topics: string; // can contain both topics and subtopics
topic: string;
subtopic: string;
};
// Make this interface require that key names look like `group-N-VOTE-count`
type VoteTallyGroupKey =
| `${string}-agree-count`
| `${string}-disagree-count`
| `${string}-pass-count`;
export interface VoteTallyCsvRow {
[key: VoteTallyGroupKey]: number;
}
//This is a type that combines VoteTallyCsvRow and CoreCommentCsvRow
export type CommentCsvRow = VoteTallyCsvRow & CoreCommentCsvRow;
/**
* Add the text and supporting comments to statementsWithComments. Also adds nested content.
* @param summaryContent the content and subcontent to add
* @param allComments all the comments from the deliberation
* @param statementsWithComments where to add new summary text and supporting source comments
* @returns none
*/
function addStatement(
summaryContent: SummaryContent,
allComments: Comment[],
statementsWithComments: { summary: string; source: string }[]
) {
if (summaryContent.subContents) {
summaryContent.subContents.forEach((subContent) => {
addStatement(subContent, allComments, statementsWithComments);
});
}
if (summaryContent.text.length === 0 && !summaryContent.title) {
return;
}
let comments: Comment[] = [];
if (summaryContent.citations) {
comments = summaryContent.citations
.map((commentId: string) => allComments.find((comment: Comment) => comment.id === commentId))
.filter((comment) => comment !== undefined);
}
statementsWithComments.push({
summary: (summaryContent.title || "") + summaryContent.text,
source: comments.map((comment) => `* [${comment.id}] ${comment.text}`).join("\n"),
});
}
/**
* Outputs a CSV where each row represents a statement and its associated comments.
*
* @param summary the summary to split.
* @param outputFilePath Path to the output CSV file that will have columns "summary" for the statement, and "comments" for the comment texts associated with that statement.
*/
export function writeSummaryToGroundedCSV(summary: Summary, outputFilePath: string) {
const statementsWithComments: { summary: string; source: string }[] = [];
for (const summaryContent of summary.contents) {
addStatement(summaryContent, summary.comments, statementsWithComments);
}
const csvWriter = createObjectCsvWriter({
path: outputFilePath,
header: [
{ id: "summary", title: "summary" },
{ id: "source", title: "source" },
],
});
csvWriter.writeRecords(statementsWithComments);
console.log(`Summary statements saved to ${outputFilePath}`);
}
/**
* Identify topics and subtopics when input data has not already been categorized.
* @param project The Vertex GCloud project name
* @param comments The comments from which topics need to be identified
* @returns Promise resolving to a Topic collection containing the newly discovered topics and subtopics for the given comments
*/
export async function getTopicsAndSubtopics(
project: string,
comments: Comment[],
keyFilename?: string
): Promise<Topic[]> {
const sensemaker = new Sensemaker({
defaultModel: new VertexModel(project, "global", "gemini-2.5-pro-preview-06-05", keyFilename),
});
return await sensemaker.learnTopics(comments, true);
}
/**
* Runs the summarization routines for the data set.
* @param project The Vertex GCloud project name
* @param comments The comments to summarize
* @param topics The input topics to categorize against
* @param additionalContext Additional context about the conversation to pass through
* @returns Promise resolving to a Summary object containing the summary of the comments
*/
export async function getSummary(
project: string,
comments: Comment[],
topics?: Topic[],
additionalContext?: string,
keyFilename?: string
): Promise<Summary> {
const sensemaker = new Sensemaker({
defaultModel: new VertexModel(project, "global", "gemini-2.5-pro-preview-06-05", keyFilename),
});
// TODO: Make the summariation type an argument and add it as a flag in runner.ts. The data
// requirements (like requiring votes) would also need updated.
const summary = await sensemaker.summarize(
comments,
SummarizationType.AGGREGATE_VOTE,
topics,
additionalContext
);
// For now, remove all Common Ground, Difference of Opinion, or TopicSummary sections
return summary.withoutContents((sc) => sc.type === "TopicSummary");
}
export function writeSummaryToHtml(summary: Summary, outputFile: string) {
const markdownContent = summary.getText("MARKDOWN");
const htmlContent = `
<!DOCTYPE html>
<html>
<head>
<title>Summary</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
</style>
${
// When in DEBUG_MODE, we need to add the DataTables and jQuery libraries, and hook
// into our table elements to add support for features like sorting and search.
process.env.DEBUG_MODE === "true"
? `
<script src="https://code.jquery.com/jquery-3.7.1.js"></script>
<script src="https://cdn.datatables.net/2.2.1/js/dataTables.js"></script>
<link rel="stylesheet" href="https://cdn.datatables.net/2.2.1/css/dataTables.dataTables.css" />
<script>$(document).ready( function () {$('table').DataTable();} )</script>
`
: ""
}
</head>
<body>
${marked(markdownContent)}
</body>
</html>`;
fs.writeFileSync(outputFile, htmlContent);
console.log(`Written summary to ${outputFile}`);
}
// Returns topics and subtopics concatenated together like
// "Transportation:PublicTransit;Transportation:Parking;Technology:Internet"
export function concatTopics(comment: Comment): string {
const pairsArray = [];
for (const topic of comment.topics || []) {
if ("subtopics" in topic) {
for (const subtopic of topic.subtopics || []) {
if ("subtopics" in subtopic && (subtopic.subtopics as Topic[]).length) {
if ("subtopics" in (subtopic as Topic)) {
for (const subsubtopic of subtopic.subtopics as Topic[]) {
pairsArray.push(`${topic.name}:${subtopic.name}:${subsubtopic.name}`);
}
}
} else {
pairsArray.push(`${topic.name}:${subtopic.name}`);
}
}
} else {
// handle case where no subtopics available
pairsArray.push(`${topic.name}`);
}
}
return pairsArray.join(";");
}
/**
* Parse a topics string from the categorization_runner.ts into a (possibly) nested topics
* array, omitting subtopics and subsubtopics if not present in the labels.
* @param topicsString A string in the format Topic1:Subtopic1:A;Topic2:Subtopic2.A
* @returns Nested Topic structure
*/
export function parseTopicsString(topicsString: string): Topic[] {
// use the new multiple topic output notation to parse multiple topics/subtopics
const subtopicMappings = topicsString
.split(";")
.reduce(
(
topicMapping: { [key: string]: Topic[] },
topicString: string
): { [key: string]: Topic[] } => {
const [topicName, subtopicName, subsubtopicName] = topicString.split(":");
// if we already have a mapping for this topic, add, otherwise create a new one
topicMapping[topicName] = topicMapping[topicName] || [];
if (subtopicName) {
let subsubtopic: Topic[] = [];
let subtopicUpdated = false;
// Check for an existing subtopic and add subsubtopics there if possible.
for (const subtopic of topicMapping[topicName]) {
if (subtopic.name === subtopicName) {
subsubtopic = "subtopics" in subtopic ? subtopic.subtopics : [];
if (subsubtopicName) {
subsubtopic.push({ name: subsubtopicName });
subtopicUpdated = true;
break;
}
}
}
if (subsubtopicName) {
subsubtopic = [{ name: subsubtopicName }];
}
if (!subtopicUpdated) {
topicMapping[topicName].push({ name: subtopicName, subtopics: subsubtopic });
}
}
return topicMapping;
},
{}
);
// map key/value pairs from subtopicMappings to Topic objects
return Object.entries(subtopicMappings).map(([topicName, subtopics]) => {
if (subtopics.length === 0) {
return { name: topicName };
} else {
return { name: topicName, subtopics: subtopics };
}
});
}
/**
* Gets comments from a CSV file, in the style of the output from the input processing files
* in the project's `bin/` directory. Core CSV rows are as for `CoreCommentCsvRow`, plus any
* vote tallies in `VoteTallyCsvRow`.
* @param inputFilePath
* @returns
*/
export async function getCommentsFromCsv(inputFilePath: string): Promise<Comment[]> {
// Determine the groups names from the header row
const header = fs.readFileSync(inputFilePath, { encoding: "utf-8" }).split("\n")[0];
const groupNames = header
.split(",")
.filter((name: string) => name.includes("-agree-count"))
.map((name: string) => name.replace("-agree-count", ""))
.sort();
const usesGroups = groupNames.length > 0;
if (!inputFilePath) {
throw new Error("Input file path is missing!");
}
const filePath = path.resolve(inputFilePath);
const fileContent = fs.readFileSync(filePath, { encoding: "utf-8" });
const parser = parse(fileContent, {
delimiter: ",",
columns: true,
});
return new Promise((resolve, reject) => {
const data: Comment[] = [];
fs.createReadStream(filePath)
.pipe(parser)
.on("error", reject)
.on("data", (row: CommentCsvRow) => {
const newComment: Comment = {
text: row.comment_text,
id: row["comment-id"].toString(),
voteInfo: getVoteInfoFromCsvRow(row, usesGroups, groupNames),
};
if (row.topics) {
// In this case, use the topics output format from the categorization_runner.ts routines
newComment.topics = parseTopicsString(row.topics);
} else if (row.topic) {
// Add topic and subtopic from single value columns if available
newComment.topics = [];
newComment.topics.push({
name: row.topic.toString(),
subtopics: row.subtopic ? [{ name: row.subtopic.toString() }] : [],
});
}
data.push(newComment);
})
.on("end", () => resolve(data));
});
}
function getVoteInfoFromCsvRow(
row: CommentCsvRow,
usesGroups: boolean,
groupNames: string[]
): VoteInfo {
if (usesGroups) {
const voteInfo: { [key: string]: VoteTally } = {};
for (const groupName of groupNames) {
voteInfo[groupName] = new VoteTally(
Number(row[`${groupName}-agree-count`]),
Number(row[`${groupName}-disagree-count`]),
Number(row[`${groupName}-pass-count`])
);
}
return voteInfo;
} else {
return new VoteTally(Number(row["agrees"]), Number(row["disagrees"]), Number(row["passes"]));
}
}
export function getTopicsFromComments(comments: Comment[]): Topic[] {
// Create a map from the topic name to a set of subtopic names.
const mapTopicToSubtopicSet: { [topicName: string]: Set<string> } = {};
for (const comment of comments) {
for (const topic of comment.topics || []) {
if (mapTopicToSubtopicSet[topic.name] == undefined) {
mapTopicToSubtopicSet[topic.name] = new Set();
}
if ("subtopics" in topic) {
for (const subtopic of topic.subtopics || []) {
mapTopicToSubtopicSet[topic.name].add(subtopic.name);
}
}
}
}
// Convert that map to a Topic array and return
const returnTopics: Topic[] = [];
for (const topicName in mapTopicToSubtopicSet) {
const topic: Topic = { name: topicName, subtopics: [] };
for (const subtopicName of mapTopicToSubtopicSet[topicName]!.keys()) {
topic.subtopics.push({ name: subtopicName });
}
returnTopics.push(topic);
}
return returnTopics;
}