feat: try to get demo working after 2 years
This commit is contained in:
commit
8e2865c5ac
7 changed files with 1358 additions and 0 deletions
12
python3/.gitignore
vendored
Normal file
12
python3/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# NLTK Data (uncomment if you want to download on each deployment)
|
||||||
|
nltk_data/
|
||||||
3
python3/README.md
Normal file
3
python3/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
|
||||||
|
|
||||||
|
uv run flask --app salience run
|
||||||
21
python3/pyproject.toml
Normal file
21
python3/pyproject.toml
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
[project]
|
||||||
|
name = "salience"
|
||||||
|
version = "0.0.0"
|
||||||
|
description = ""
|
||||||
|
authors = [
|
||||||
|
{ name = "Matt Neary", email = "neary.matt@gmail.com" }
|
||||||
|
]
|
||||||
|
license = { text = "MIT" }
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"flask>=2.3.2,<3.0.0",
|
||||||
|
"transformers>=4.30.2,<5.0.0",
|
||||||
|
"nltk>=3.8.1,<4.0.0",
|
||||||
|
"sentence-transformers>=2.2.2,<3.0.0",
|
||||||
|
"numpy>=1.25.0,<2.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
18
python3/salience/__init__.py
Normal file
18
python3/salience/__init__.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
from flask import Flask
|
||||||
|
import numpy as np
|
||||||
|
from .salience import extract
|
||||||
|
import json
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
with open('./transcript.txt', 'r') as file:
|
||||||
|
source_text = file.read().strip()
|
||||||
|
sentence_ranges, adjacency = extract(source_text)
|
||||||
|
|
||||||
|
@app.route("/salience")
|
||||||
|
def salience_view():
|
||||||
|
return json.dumps({
|
||||||
|
'source': source_text,
|
||||||
|
'intervals': sentence_ranges,
|
||||||
|
'adjacency': np.nan_to_num(adjacency.numpy()).tolist(),
|
||||||
|
})
|
||||||
63
python3/salience/salience.py
Normal file
63
python3/salience/salience.py
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
from sentence_transformers import SentenceTransformer
|
||||||
|
import nltk.data
|
||||||
|
import nltk
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Set NLTK data path to project directory
|
||||||
|
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
NLTK_DATA_DIR = os.path.join(PROJECT_DIR, 'nltk_data')
|
||||||
|
|
||||||
|
# Add to NLTK's search path
|
||||||
|
nltk.data.path.insert(0, NLTK_DATA_DIR)
|
||||||
|
|
||||||
|
# Download to the custom location
|
||||||
|
nltk.download('punkt', download_dir=NLTK_DATA_DIR)
|
||||||
|
|
||||||
|
model = SentenceTransformer('all-mpnet-base-v2')
|
||||||
|
sent_detector = nltk.data.load('tokenizers/punkt/english.pickle')
|
||||||
|
|
||||||
|
def cos_sim(a, b):
|
||||||
|
sims = a @ b.T
|
||||||
|
a_norm = np.linalg.norm(a, axis=-1)
|
||||||
|
b_norm = np.linalg.norm(b, axis=-1)
|
||||||
|
a_normalized = (sims.T / a_norm.T).T
|
||||||
|
sims = a_normalized / b_norm
|
||||||
|
return sims
|
||||||
|
|
||||||
|
def degree_power(A, k):
|
||||||
|
degrees = np.power(np.array(A.sum(1)), k).ravel()
|
||||||
|
D = np.diag(degrees)
|
||||||
|
return D
|
||||||
|
|
||||||
|
def normalized_adjacency(A):
|
||||||
|
normalized_D = degree_power(A, -0.5)
|
||||||
|
return torch.from_numpy(normalized_D.dot(A).dot(normalized_D))
|
||||||
|
|
||||||
|
def get_sentences(source_text):
|
||||||
|
sentence_ranges = list(sent_detector.span_tokenize(source_text))
|
||||||
|
sentences = [source_text[start:end] for start, end in sentence_ranges]
|
||||||
|
return sentences, sentence_ranges
|
||||||
|
|
||||||
|
def text_rank(sentences):
|
||||||
|
vectors = model.encode(sentences)
|
||||||
|
adjacency = torch.tensor(cos_sim(vectors, vectors)).fill_diagonal_(0.)
|
||||||
|
adjacency[adjacency < 0] = 0
|
||||||
|
return normalized_adjacency(adjacency)
|
||||||
|
|
||||||
|
def terminal_distr(adjacency, initial=None):
|
||||||
|
sample = initial if initial is not None else torch.full((adjacency.shape[0],), 1.)
|
||||||
|
scores = sample.matmul(torch.matrix_power(adjacency, 10)).numpy().tolist()
|
||||||
|
return scores
|
||||||
|
|
||||||
|
def extract(source_text):
|
||||||
|
sentences, sentence_ranges = get_sentences(source_text)
|
||||||
|
adjacency = text_rank(sentences)
|
||||||
|
return sentence_ranges, adjacency
|
||||||
|
|
||||||
|
def get_results(sentences, adjacency):
|
||||||
|
scores = terminal_distr(adjacency)
|
||||||
|
for score, sentence in sorted(zip(scores, sentences), key=lambda xs: xs[0]):
|
||||||
|
if score > 1.1:
|
||||||
|
print('{:0.2f}: {}'.format(score, sentence))
|
||||||
120
python3/salience/static/index.html
Normal file
120
python3/salience/static/index.html
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
CTYPE HTML>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf8" />
|
||||||
|
<title>Salience</title>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/mathjs/11.8.0/math.js" integrity="sha512-VW8/i4IZkHxdD8OlqNdF7fGn3ba0+lYqag+Uy4cG6BtJ/LIr8t23s/vls70pQ41UasHH0tL57GQfKDApqc9izA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
width: 700px;
|
||||||
|
margin: 1em auto;
|
||||||
|
color: #4d4d4d;
|
||||||
|
font-family: sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.33em;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
width: 700px;
|
||||||
|
text-align: left;
|
||||||
|
margin: 15px auto;
|
||||||
|
margin-bottom: 0;
|
||||||
|
color: #000;
|
||||||
|
font-family: sans-serif;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
h1 span {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.7em;
|
||||||
|
font-weight: normal;
|
||||||
|
color: #a0a0a0;
|
||||||
|
}
|
||||||
|
span.sentence {
|
||||||
|
--salience: 1;
|
||||||
|
background-color: rgba(249, 239, 104, var(--salience));
|
||||||
|
}
|
||||||
|
span.highlight {
|
||||||
|
background-color: rgb(185, 225, 244);
|
||||||
|
}
|
||||||
|
::selection {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>
|
||||||
|
Salience
|
||||||
|
<span>automatic sentence highlights based on their significance to the document</span>
|
||||||
|
</h1>
|
||||||
|
<p id="content"></p>
|
||||||
|
<script type="text/javascript">
|
||||||
|
const content = document.querySelector('#content')
|
||||||
|
let adjacency = null
|
||||||
|
function scale(score) {
|
||||||
|
return Math.max(0, Math.min(1, score ** 3 - 0.95))
|
||||||
|
}
|
||||||
|
let exponent = 5
|
||||||
|
const redraw = () => {
|
||||||
|
if (!adjacency) return
|
||||||
|
const sentences = document.querySelectorAll('span.sentence')
|
||||||
|
if (!window.getSelection().isCollapsed) {
|
||||||
|
const sel = window.getSelection()
|
||||||
|
const fromNode = sel.anchorNode.parentNode
|
||||||
|
const toNode = sel.extentNode.parentNode
|
||||||
|
const fromIdx = Array.from(sentences).indexOf(fromNode)
|
||||||
|
const toIdx = Array.from(sentences).indexOf(toNode)
|
||||||
|
const range = [fromIdx, toIdx]
|
||||||
|
console.log('range', range)
|
||||||
|
range.sort((a, b) => a - b)
|
||||||
|
const vec = adjacency.map((x, i) => (i >= range[0] && i <= range[1]) ? 1 : 0)
|
||||||
|
const vec_sum = vec.reduce((a, x) => a + x, 0)
|
||||||
|
const scores = math.multiply(vec, adjacency).map(x => x * adjacency.length / vec_sum)
|
||||||
|
Array.from(sentences).forEach((node, i) => {
|
||||||
|
node.style.setProperty('--salience', scale(scores[i]))
|
||||||
|
if (i >= range[0] && i <= range[1]) node.classList.add('highlight')
|
||||||
|
else node.classList.remove('highlight')
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const initial = adjacency.map(() => 1)
|
||||||
|
const scores = math.multiply(initial, math.pow(adjacency, exponent))
|
||||||
|
Array.from(sentences).forEach((node, i) => {
|
||||||
|
node.style.setProperty('--salience', scale(scores[i]))
|
||||||
|
node.classList.remove('highlight')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Disabled functionality to center highlights on a selected fragment
|
||||||
|
// document.addEventListener('mousemove', redraw)
|
||||||
|
// document.addEventListener('mouseup', redraw)
|
||||||
|
fetch('/salience').then(async res => {
|
||||||
|
const data = await res.json()
|
||||||
|
console.log(data)
|
||||||
|
const source = data.source
|
||||||
|
const intervals = data.intervals
|
||||||
|
const tokens = intervals.map(([start, end]) => source.substr(start, end - start))
|
||||||
|
adjacency = data.adjacency
|
||||||
|
tokens.forEach((t, i) => {
|
||||||
|
const token = document.createElement('span')
|
||||||
|
token.innerText = t
|
||||||
|
token.classList.add('sentence')
|
||||||
|
content.appendChild(token)
|
||||||
|
if (tokens[i+1] && intervals[i+1][0] > intervals[i][1]) {
|
||||||
|
const intervening = document.createElement('span')
|
||||||
|
const start = intervals[i][1]
|
||||||
|
intervening.innerText = source.substr(start, intervals[i+1][0] - start)
|
||||||
|
content.appendChild(intervening)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
redraw()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
1121
python3/uv.lock
generated
Normal file
1121
python3/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue