跳到主要內容

Greatest notes compiler

 1. Greatest notes format

平常我就有作筆記的習慣,其中大部份的筆記都是非結構化的文字檔,部份是 markdown 格式,這樣在任何平台只要有文字編輯器,就能很方便檢視及編輯,而不必依賴特定的應用程式。

久了就慢慢累積了不少資料,有日記、工作日誌、筆記、雜記等等。為了方便檢視,我也作了一個小 app,可以讓我在一個畫面內快速檢視我的筆記列表,再點入去作進一步的檢視各別資料或統計資訊等。

雖說這些筆記幾乎是非結構化的,但其實還是有少量的規則,這樣我的程式才能作簡單的解析。例如日記或工作日誌基於日期的資料,至少有個日期標題和不定長度的內文,或是筆記類的資料至少也有個標題和不定長度的內文,等等。

漸漸的我盡量把類似性質的筆記的格式統一起來,最後我發現其實可以用一個很簡單的格式全部統一。它就是 Greatest notes 格式。

Greatest notes 文件是文字檔。內容由不定數量的項目組成,每個項目由一個標題和不定長度的內文組成。每一個項目由每一行開頭的">>> "開始,直到下一個">>> "或者是檔尾,就是一個項目。

例如:

>>> 這是一則筆記
內文
內文
內文
...

>>> 另一則筆記
內文
內文
...

格式很簡單,要寫一個解析器也很簡單。因為只需要 re 以 ">>> " 對整個檔案切割,再從每個項目的第一行取出標題,其餘為內容。

例如下面就是一段可以解析 Greatest notes 文件的 javascript 程式。

// javascript
let notes = raw.split(/^>>> /gm).map(note => {
    let i = note.indexOf('\n');
    let title = note.substring(0, i);
    let content = note.substring(i + 1);
    return {
        title: title !== '' ? title : '未命名筆記',
        content: content
    };
}).filter(note => note.content !== ''); // Remove empty notes.

因為這是一個極簡的格式,只以 ">>> " 對檔案內容作區隔。缺少巢狀結構和 metadata 等。但實際應用時,能在標題帶入某種簡單的格式作為 metadata 的額外補充。例如日記或日誌等資料,可以在標題中統一以一個日期開始等。

有了這個簡單的格式,要建立筆記就方便了。在標題加上少量的自定義 metadata 就能進一步增強文件的結構,有利於進一步整理及資料分析。

2. Greatest notes compiler

近幾年 LLM 崛起,我也和大家一樣利用 AI 輔助探索了許多感興趣或遭遇到的問題。

OpenAI ChatGPT 有個對我很方便的地方是,可以容易的把整個對話資料匯出備份。匯出的資料全部被打包成一個 zip,下載後解壓。裡面有一個對話資料有關的 json 檔案。

後來我利用 AI 輔助,寫了一個讀取並解析此檔的工具程式,可以在電腦上的 browser 檢視。之所以會另外作一個工具檢視,而不是只使用官方的 app。是因為我常常會回頭檢視歷史紀錄,而當對話很長時,檢視已經變得很不方便。我作的小工具主要是為了提供一個大綱模式,讓我可以快速跳至指定段落,方便閱讀。

因為前面我已經發展出 Greatest notes 格式和手機檢視小工具,所以很自然的,我也把 ChatGPT 的 json 格式的對話紀錄轉成 Greatest notes 格式,並匯入我的小工具,在手機上方便隨時檢閱。

但是長對話的問題還是存在。超長的文件不管用什麼方式檢視都是個問題。就算我把那些問答式的對話,或是些無關緊要或價值不大的對話刪除,轉換成 Greatest notes 後,許多對話都有幾萬字,整個文件將近一百萬字。

基於這個原因,我才發展出 Greatest notes compiler,可以把 Greatest notes 文件內容壓縮,提取摘要和重點,過濾掉不是重點的內容。最後一樣輸出另一個 Greatest notes,這樣我可以互相比對參考。

2.1 Greatest notes compiler pipeline

Greatest notes 編譯器是一個 pipeline 流水線,類似一般的程式語言的編譯和連結流程。

load_greatest_notes
   ↓                載入 Greatest notes
do_compile
   ↓                對個別 note 編譯,輸出個別 json
merge
   ↓                合併所有 json,輸出最終 Greatest notes
  完成

2.1.1 編譯流程

編譯流程首先載入 Greatest notes 後,分別對個別的 note 執行編譯。每個 note 編譯完成後會個別輸出一個 temp 的 json 檔案,類似編譯 c/c++ 程式時,會產生中間的 obj 檔案。分成幾個步驟如下:

compile_note
   ↓                對單一則 note 編譯
chunk_text
   ↓                對 note 以固定長度作切割
summarize_chunk
   ↓                對 chunk 內容作摘要提取 
extract_claims
   ↓                對 chunk 內容作要點提取
  完成

2.1.1.1 chunk_text

chunk_text 主要目的是對輸入的 note 內文作切割,避免單一 note 內文過大,造成後續編譯步驟出問題。

切割的方式很簡單很暴力,就是以固定長度對輸入文字切割。目前以 chunk_size = 2000 作區段大小處理。雖然用這麼簡單又暴力的方式切割文字,但實測結果並沒有什麼明顯的問題,所以也就暫時都這樣作了。

2.1.1.2 summarize_chunk

summarize_chunk 利用 LLM 的語言能力對輸入文字作摘要提取。目前使用的模型是 gemma3:12b-it-q4_K_M,測試結果對我而言很好。

提示詞如下:

Summarize the following conversation chunk.
只用繁體中文回答.

Return JSON:
{{
  "summary": "...",
  "main_points": []
}}

Text:
{text}

2.1.1.3 extract_claims

extract_claims 一樣利用 gemma3:12b-it-q4_K_M 作為輸入文字的重點提取引擎。

提取出來的重點除了文字以外還有一個 type,這是跟未來替編譯流程加上下一步驟有關:建立重點之間的關聯以建立知識圖譜。

提示詞如下:

Extract atomic claims from the text.

Constraints:
- Each claim must represent a DISTINCT semantic proposition.
- Do not produce multiple claims that can be merged.
- If two sentences imply the same normative rule, merge them.
- Prefer canonical, abstract, policy-level phrasing.
- Maximum 10 claims per chunk.
- Remove rhetorical emphasis.
- No restatements.
- 只用繁體中文回答.

If the text repeats an idea, output it only once.

Return JSON:
{{
  "claims": [
    {{
      "text": "...",
      "type": "normative | descriptive | inference"
    }}
  ]
}}

Text:
{text}

2.1.2 連結流程

連結流程比較簡單,也是類似 c/c++ 程式最後的 link 動作將所有 objs/libs 等連結成一個 exe 或 dll 等。而 Greatest notes compiler 的連結動作則是把所有個別 note 編譯成功後輸出的 json 檔,全部合拼再輸出成一個 Greatest notes 文件。

下面是編譯後的一篇 note 的範例。



留言

這個網誌中的熱門文章

以lex/yacc實作算式計算機

前面我們透過 手工的方式 實作了一個簡易的算式計算機,現在我們要開始使用工具來作同樣的事,比較看看手工和使用工具有什麼不同的差別。首先要介紹的就是lex&yacc。 lex & yacc lex(Lexical Analyzar)及yacc(Yet Another Compiler Compiler)是用來輔助程式設計師製作語法剖析器的程式工具。lex的工作就是幫助我們將輸入的資料文字串流分解成一個個有意義的token,而yacc的工作就是幫我們分析這些token和我們定義的規則作匹配。下圖中所表示的是使用lex及yacc的一般工作流程。 首先看到yacc會讀入一個.y檔案,這裡.y檔案的內容就是我們使用類似(E)BNF語法定義的語法規則,yacc會分析這些語法規則後,幫我們產生可以用來解析這些規則的程式碼,而這個檔案一般名稱預設為y.tab.c,產生的程式碼裡面最重要的一個的函式叫作yyparse。 同yacc類似,lex也會讀入一個.l的檔案,這個檔案裡面定義的是如何從文字流裡解出token的規則,使用的方法是常規表示式(regular expression)。在圖的左側中間我們還可以看到有一個叫作y.tab.h的檔案從yacc產生出來並餵給lex作輸入,這個檔案是yacc根據在讀入的.y檔裡面所定義的token代號所產生出來的一個header,這樣yacc及lex產生出來的程式碼裡面就可以使用共通定義的代碼而不必各寫個的。lex分析過.l檔案後也會產生一個一般預設叫作lex.yy.c的原始碼檔案,裡頭最重要的一個函式叫作yylex。 最後,我們把yacc產生出來的y.tab.c還有lex產生出來的lex.yy.c,以及其它我們自己撰寫的原始碼檔案一起拿來編譯再作連結,最後產生出來的就是一個可以用來解析我們定義的語法的解析器工具。以上是整個lex及yacc的使用流程概觀。 常規表示式 在正式使用lex之前,我們首先來對常規表示法作一個基本的認識。常規表示法是一種用來表示字串樣式(pattern)的中繼語言,就好比前文所介紹的(E)BNF表示式一樣,都是用來描述其它語言的語言,只不過用途不太一樣罷了。 常規表示式使用一些中繼符號(meta-symbol)以及ASCII字元定義字串樣式,以下列出一些常規表示式所使用的符號。 . 表示除了換行字元...

窮人的 AI:自動漫畫分鏡切割

  ( 試試看 ) 在手機上看漫畫時,有一個體驗上的問題: 漫畫原本是「整頁設計」 手機最適合的是「一格一格往下滑」 與其強迫使用者縮放、拖曳、放大,更直覺的做法是: 直接把一頁漫畫自動切成多個分鏡,轉成瀑布流閱讀。 這篇文章分享一個不靠深度學習、完全在前端完成的實作方式: 使用 OpenCV.js 做分鏡偵測 輸出 rect list 再用 全畫面 Canvas 把每個分鏡當成一個「閱讀單位」 整個系統可以拆成三層: 漫畫圖片 ↓ 影像處理(找出 rects) ↓ 排序後的 rect list ↓ 全畫面 Canvas 逐格呈現(瀑布流) Step 1:灰階化 漫畫的資訊 90% 都在線條上,顏色反而是干擾。 cv.cvtColor(src, grayImage, cv.COLOR_RGBA2GRAY); 灰階化的好處: 降低維度 對邊緣偵測更穩定 對黑白漫畫特別有效 Step 2:邊緣偵測,抓出「分鏡的邊」 接下來用最經典、也最夠用的 Canny Edge Detection: cv.Canny(grayImage, edges, 50, 150); 在漫畫中,分鏡外框通常就是最明顯的邊界。 Step 3:形態學操作,把破碎邊框「補起來」 真實漫畫的線條並不完美,常常有斷線、陰影、留白。 所以要做一個很重要的步驟:膨脹(Dilation) const kernel = cv.Mat.ones( 5 , 5 , cv.CV_8U); cv.dilate(edges, dilatedEdges, kernel); 直覺理解就是: 把細線「抹粗一點」, 讓本來斷掉的邊界連成封閉區域。 這一步直接決定後面能不能成功抓到「一整格分鏡」。 Step 4:找輪廓,轉成矩形框(rect) 有了封閉區域之後,就可以找輪廓: cv.findContours( dilatedEdges, contours, hierarchy, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE ); 每一個 contour,代表一個「可能的分鏡區塊」。 接著轉成矩形: const rect = cv.boundingRect(contour); rects.push([rect.x, rect.y, rect.widt...

猜數字遊戲 (電腦猜人)

前幾天午睡時突然被告知要參加公司內部的程式設計比賽,題目是用C寫一支文字模式的4位數字猜數字遊戲,由使用者來猜電腦的數字。在上星期時其實就已經有公佈了,但我沒有注意到所以是臨時加入,還好這是個簡單的題目,不用花多少時間就可以寫出來。 規則: - 這是一對一比賽,雙方各選擇一4位數字,不讓對方知道。 - 4位數字由數字0至9組成,每位數不得重複。 - 雙方輪流猜對方的數字,直到一方猜中為止。 - A方猜B方的數字後,B方根據A方的猜測回答幾A幾B。 - 一個A表示猜中一個數字且位置正確,一個B表示猜中一個數字但位置不正確。 - 當一方猜中4A0B時即表示猜中對方全部4個數字且位置正確,贏得比賽。 - 例:B的謎底是4208,底下箭頭左測是A的猜測,箭頭右測是B的回答。    1234 ==> 1A1B    5678 ==> 1A0B    2406 ==> 1A2B    ...    4208 ==> 4A0B ; 寫個程式讓玩家來猜電腦的數字不難,不過我從來沒有寫過讓電腦來猜玩家數字的版本,所以花了點時間想想怎麼寫。 研究後歸納出二個點。 1, 使用窮舉法將所有可能數字組合列出。 2, 每次猜測後根據結果排除不可能是答案的組合,重複這個動作直到猜中答案為止。 第1點只是實作問題,第2點概念也很簡單,但要過濾不是答案的組合根據的是什麼?乍看之下沒什麼頭緒,不過想通之後就非常簡單了。 它的基本原理如下:假如謎底是4561,如果猜1524則會得到1A2B。從相反的角度來看,如果謎底是1524,則猜4561時也會得到1A2B的回答。 利用這個方法,每一次猜測一個數字X後,再以這個數字當作答案,來和所有剩下來的候選答案作比對,如果得到的結果(幾A幾B)和數字X是一樣的話,就把這個數字保留下來繼續作為候選答案,否則就過把這個數字過濾掉。下一把,繼續從候選答案裡選一個出來猜,重複上面的動作,直到猜中為止。 ; C++ STL的algorithm裡有個叫作next_permutation的函數,可以用來生成排列。 #include <iostream> #include <algorithm> using namespace std; int main () {   int myints[] = {1,2,3};  ...