2025年1月

我们在项目开发中,为了保证系统功能完整、准确性,我们都需要模拟真实数据进行测试。

今天推荐一个开源库,方便我们制造假数据测试。

01 项目简介

Bogus 是一个开源的 .NET 库,它提供了一个强大的工具集,用于生成虚假(mock)数据。方便项目用于测试、填充数据库、创建模拟数据集或生成示例数据,以便开发、测试、演示使用。

02 项目特点

1、简单易用:提供了一个简单直观的 API,使得生成各种类型的数据变得非常容易。

2、定制性强:用户可以定制生成的假数据,包括自定义格式、规则和数据类型。

3、扩展性:允许开发人员创建自己的数据生成器和处理程序,以支持特定的数据格式或结构。

4、丰富的数据类型:支持生成多种类型的数据,包括但不限于名字、地址、日期、电话号码、电子邮件地址等。

5、本地化支持:支持不同地区和文化的本地化数据生成,这使得它能够生成特定语言和地区的假数据。

6、可配置的随机性:可以配置随机种子,以便在需要可重复结果的测试场景中使用。

7、复合类型支持:能够生成复合对象的假数据,包括对象图和复杂类型的数据。

8、日期和时间生成:可以生成随机的日期和时间数据,并且可以指定范围。

9、可与其他库集成:可以轻松地与 Entity Framework 等 ORM 工具集成,用于数据库的种子数据生成。

03 使用方法

1、安装依赖库

Install-Package Bogus

2、示例代码

using Bogus;

// 创建一个 Faker 实例
var faker = new Faker();

//示例1:生成一个随机的名字
string name = faker.Person.FullName;
Console.WriteLine(name);

//示例2:生成一个随机的地址
string address = faker.Address.FullAddress();
Console.WriteLine(address);

//示例3:生成一个随机的日期
DateTime date = faker.Date.Past();
Console.WriteLine(date);

//示例4
// 创建一个自定义的数据生成器
var customGenerator = new Faker<Address>()
    .RuleFor(a => a.Street, f => f.Address.StreetName())
    .RuleFor(a => a.City, f => f.Address.City());

// 使用自定义生成器生成一个地址对象
Address generatedAddress = customGenerator.Generate();
Console.WriteLine($"Street: {generatedAddress.Street}, City: {generatedAddress.City}");

public class Address
{
    public string Street { get; set; }
    public string City { get; set; }
}

3、效果

图片

04 项目地址

https://github.com/bchavez/Bogus

- End -

更多开源项目:
https://github.com/bianchenglequ/NetCodeTop

推荐阅读

.NET日志库:Serilog、NLog、Log4Net等十大开源日志库大盘点!

ImageSharp:高性能跨平台.NET开源图形库

DateTimeExtensions:一个轻量C#的开源DateTime扩展方法库

一个C#开源工具库,集成了超过1000个扩展方法

Plotly.NET:一个强大的、漂亮的.NET开源交互式图表库

Insecure CAPTCHA(不安全验证)

Insecure CAPTCHA(不安全验证)
漏洞指的是在实现 CAPTCHA(完全自动化公共图灵测试区分计算机和人类)机制时,未能有效保护用户输入的验证信息,从而使得攻击者能够绕过或破解该验证机制。这类漏洞通常出现在网络应用程序中,目的是防止自动化脚本(如机器人)对网站进行滥用,CAPTCHA全称为Completely Automated Public Turing Test to Tell Computers and Humans Apart,中文名字是
全自动区分计算机和人类的图灵测试

low

正常修改会报错

重新修改密码并抓包发送到重放器


step=1
修改为
step=2
,发包

修改成功

源码审计

并没有什么过滤,设置了
step=2
才能修改,使用
**mysqli_real_escape_string**
可能SQL注入;使用了不安全的
md5
加密算法

<?php

if (isset($_POST['Change']) && ($_POST['step'] == '1')) { 
    // Step 1: 用户提交了第一个表单,并且是第一步
    $hide_form = true; // 标识隐藏CAPTCHA表单

    // 获取用户输入的新密码和确认密码
    $pass_new  = $_POST['password_new'];
    $pass_conf = $_POST['password_conf'];

    // 通过第三方服务检查CAPTCHA
    $resp = recaptcha_check_answer(
        $_DVWA['recaptcha_private_key'],
        $_POST['g-recaptcha-response']
    );

    // CAPTCHA验证未通过
    if (!$resp) {
        $html     .= "<pre><br />The CAPTCHA was incorrect. Please try again.</pre>";
        $hide_form = false; // 如果错误,不隐藏表单
        return;
    } else {
        // CAPTCHA验证通过,检查两次输入的密码是否匹配
        if ($pass_new == $pass_conf) {
            // 如果匹配,让用户确认更改
            $html .= "
                <pre><br />You passed the CAPTCHA! Click the button to confirm your changes.<br /></pre>
                <form action=\"#\" method=\"POST\">
                    <input type=\"hidden\" name=\"step\" value=\"2\" />
                    <input type=\"hidden\" name=\"password_new\" value=\"{$pass_new}\" />
                    <input type=\"hidden\" name=\"password_conf\" value=\"{$pass_conf}\" />
                    <input type=\"submit\" name=\"Change\" value=\"Change\" />
                </form>";
        } else {
            // 两次输入的密码不匹配
            $html     .= "<pre>Both passwords must match.</pre>";
            $hide_form = false; // 不隐藏表单,提示用户重新输入
        }
    }
}
if (isset($_POST['Change']) && ($_POST['step'] == '2')) { 
    // Step 2: 用户提交确认后的表单,进行更改操作
    $hide_form = true; // 隐藏CAPTCHA表单

    // 获取用户输入的新密码和确认密码
    $pass_new  = $_POST['password_new'];
    $pass_conf = $_POST['password_conf'];

    // 确认两个密码匹配
    if ($pass_new == $pass_conf) {
        // 对特殊字符进行转义,防止SQL注入
        $pass_new = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $pass_new) : "");

        // 将密码进行md5加密(注:md5已不再安全,实际应用中应使用更安全的加密方式)
        $pass_new = md5($pass_new);

        // 更新数据库中当前用户的密码
        $insert = "UPDATE `users` SET password = '$pass_new' WHERE user = '" . dvwaCurrentUser() . "';";
        $result = mysqli_query($GLOBALS["___mysqli_ston"], $insert) or die('<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"]) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>');

        // 给用户反馈密码已更改
        $html .= "<pre>Password Changed.</pre>";
    } else {
        // 两次输入的密码不匹配
        $html .= "<pre>Passwords did not match.</pre>";
        $hide_form = false; // 提示错误,不隐藏表单
    }

    // 关闭数据库连接
    ((is_null($___mysqli_res = mysqli_close($GLOBALS["___mysqli_ston"]))) ? false : $___mysqli_res);
}
?>

medium

同样修改后抓包

这里查看源码可以发现设置了
passed_captcha
验证


step=1
修改为
step=2
,并且添加
passed_captcha=true

修改成功

源码审计

与low级别差不多,多了一个设置
passed_captcha=true
才能正常修改

?php

if (isset($_POST['Change']) && ($_POST['step'] == '1')) {
    // 第一步:用户提交了表单且处于步骤1
    $hide_form = true; // 标识隐藏CAPTCHA表单

    // 获取用户输入的新密码和确认密码
    $pass_new = $_POST['password_new'];
    $pass_conf = $_POST['password_conf'];

    // 从第三方验证CAPTCHA
    $resp = recaptcha_check_answer(
        $_DVWA['recaptcha_private_key'],
        $_POST['g-recaptcha-response']
    );

    // CAPTCHA验证未通过
    if (!$resp) {
        $html .= "<pre><br />The CAPTCHA was incorrect. Please try again.</pre>";
        $hide_form = false; // 如果错误,不隐藏表单
        return;
    } else {
        // CAPTCHA验证通过,检查两次输入的密码是否匹配
        if ($pass_new == $pass_conf) {
            // 密码匹配,显示下一步
            $html .= "
                <pre><br />You passed the CAPTCHA! Click the button to confirm your changes.<br /></pre>
                <form action=\"#\" method=\"POST\">
                    <input type=\"hidden\" name=\"step\" value=\"2\" />
                    <input type=\"hidden\" name=\"password_new\" value=\"{$pass_new}\" />
                    <input type=\"hidden\" name=\"password_conf\" value=\"{$pass_conf}\" />
                    <input type=\"hidden\" name=\"passed_captcha\" value=\"true\" />
                    <input type=\"submit\" name=\"Change\" value=\"Change\" />
                </form>";
        } else {
            // 两次输入的密码不匹配
            $html .= "<pre>Both passwords must match.</pre>";
            $hide_form = false; // 不隐藏表单,提示用户重新输入
        }
    }
}
if (isset($_POST['Change']) && ($_POST['step'] == '2')) {
    // 第二步:用户提交确认后的表单
    $hide_form = true; // 隐藏CAPTCHA表单

    // 获取用户输入的新密码和确认密码
    $pass_new = $_POST['password_new'];
    $pass_conf = $_POST['password_conf'];

    // 确保用户完成了第一步
    if (!$_POST['passed_captcha']) {
        $html .= "<pre><br />You have not passed the CAPTCHA.</pre>";
        $hide_form = false;
        return;
    }
    // 检查两次输入的密码是否匹配
    if ($pass_new == $pass_conf) {
        // 匹配进行密码更新
        // 转义特殊字符,防止SQL注入
        $pass_new = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $pass_new) : "");
        
        // 使用md5加密密码(注意:md5不够安全,实际应用中应使用更好的加密方法)
        $pass_new = md5($pass_new);

        // 更新数据库中的用户密码
        $insert = "UPDATE `users` SET password = '$pass_new' WHERE user = '" . dvwaCurrentUser() . "';";
        $result = mysqli_query($GLOBALS["___mysqli_ston"], $insert) or die('<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"]) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>');

        // 反馈用户密码已更改
        $html .= "<pre>Password Changed.</pre>";
    } else {
        // 两次输入的密码不匹配
        $html .= "<pre>Passwords did not match.</pre>";
        $hide_form = false;
    }
    // 关闭数据库连接
    ((is_null($___mysqli_res = mysqli_close($GLOBALS["___mysqli_ston"]))) ? false : $___mysqli_res);
}
?>

high

定位登录框,发现这么一处注释

 **DEV NOTE**   Response: 'hidd3n_valu3'   &&   User-Agent: 'reCAPTCHA'   **/DEV NOTE** 

结合源码得知需要
g-recaptcha-response=hidd3n_valu3
并且
User-Agent: 'reCAPTCHA'

同样修改后抓包

发送包并修改参数

修改成功

源码审计

设置了请求头:
reCAPTCHA ; g-recaptcha-response = hidd3n_valu3
,以及token使会话更有安全性,还利用CSRF令牌使的更安全

<?php

if (isset($_POST['Change'])) {
    // 用户提交了表单,隐藏CAPTCHA表单
    $hide_form = true;

    // 获取用户输入的新密码和确认密码
    $pass_new = $_POST['password_new'];
    $pass_conf = $_POST['password_conf'];

    // 验证CAPTCHA
    $resp = recaptcha_check_answer(
        $_DVWA['recaptcha_private_key'],
        $_POST['g-recaptcha-response']
    );

    // 检查CAPTCHA验证是否通过或符合内置绕过条件
    if (
        $resp || 
        (
            $_POST['g-recaptcha-response'] == 'hidd3n_valu3'
            && $_SERVER['HTTP_USER_AGENT'] == 'reCAPTCHA'
        )
    ) {
        // CAPTCHA验证通过,检查两次输入的密码是否匹配
        if ($pass_new == $pass_conf) {
            // 转义输入以防止SQL注入攻击
            $pass_new = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $pass_new) : "");
            
            // 使用md5加密密码(注意:不推荐在生产环境中使用)
            $pass_new = md5($pass_new);

            // 更新数据库用户密码
            $insert = "UPDATE `users` SET password = '$pass_new' WHERE user = '" . dvwaCurrentUser() . "' LIMIT 1;";
            $result = mysqli_query($GLOBALS["___mysqli_ston"], $insert) or die('<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"]) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>');

            // 返回用户的反馈信息
            $html .= "<pre>Password Changed.</pre>";
        } else {
            // 如果密码不匹配
            $html .= "<pre>Both passwords must match.</pre>";
            $hide_form = false;
        }
    } else {
        // CAPTCHA输入错误时的响应
        $html .= "<pre><br />The CAPTCHA was incorrect. Please try again.</pre>";
        $hide_form = false;
        return;
    }

    // 关闭数据库连接
    ((is_null($___mysqli_res = mysqli_close($GLOBALS["___mysqli_ston"]))) ? false : $___mysqli_res);
}

// 生成反CSRF攻击的令牌
generateSessionToken();
?>

impossible

源码审计

结合反CSRF令牌和CAPTCHA,提高安全性;并且使用PDO和参数绑定防止SQL注入。

<?php
if (isset($_POST['Change'])) {
    // 检查反CSRF令牌,确保请求的合法性
    checkToken($_REQUEST['user_token'], $_SESSION['session_token'], 'index.php');

    // 隐藏CAPTCHA表单
    $hide_form = true;

    // 获取用户输入的新密码,并移除转义字符
    $pass_new = $_POST['password_new'];
    $pass_new = stripslashes($pass_new);
    $pass_new = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $pass_new) : "");
    $pass_new = md5($pass_new); // 对新密码进行MD5加密

    // 获取用户输入的确认密码,并移除转义字符
    $pass_conf = $_POST['password_conf'];
    $pass_conf = stripslashes($pass_conf);
    $pass_conf = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $pass_conf) : "");
    $pass_conf = md5($pass_conf); // 对确认密码进行MD5加密

    // 获取用户输入的当前密码,并移除转义字符
    $pass_curr = $_POST['password_current'];
    $pass_curr = stripslashes($pass_curr);
    $pass_curr = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $pass_curr) : "");
    $pass_curr = md5($pass_curr); // 对当前密码进行MD5加密

    // 使用第三方功能验证CAPTCHA
    $resp = recaptcha_check_answer(
        $_DVWA['recaptcha_private_key'],
        $_POST['g-recaptcha-response']
    );

    // 如果CAPTCHA验证失败
    if (!$resp) {
        // 反馈信息:CAPTCHA错误
        $html .= "<pre><br />The CAPTCHA was incorrect. Please try again.</pre>";
        $hide_form = false;
    } else {
        // 检查当前密码是否正确
        $data = $db->prepare('SELECT password FROM users WHERE user = (:user) AND password = (:password) LIMIT 1;');
        $data->bindParam(':user', dvwaCurrentUser(), PDO::PARAM_STR);
        $data->bindParam(':password', $pass_curr, PDO::PARAM_STR);
        $data->execute();

        // 检查新密码是否匹配,且当前密码是否正确
        if (($pass_new == $pass_conf) && ($data->rowCount() == 1)) {
            // 更新数据库中的用户密码
            $data = $db->prepare('UPDATE users SET password = (:password) WHERE user = (:user);');
            $data->bindParam(':password', $pass_new, PDO::PARAM_STR);
            $data->bindParam(':user', dvwaCurrentUser(), PDO::PARAM_STR);
            $data->execute();

            // 用户反馈:成功
            $html .= "<pre>Password Changed.</pre>";
        } else {
            // 用户反馈:失败
            $html .= "<pre>Either your current password is incorrect or the new passwords did not match.<br />Please try again.</pre>";
            $hide_form = false;
        }
    }
}

// 生成反CSRF攻击的令牌
generateSessionToken();
?>

文本概括是大语言模型的常用功能之一,我们总结一段文字、一篇文章的主要内容,一篇论文的摘要,甚至一本书的简介都属于文本概括的范畴。听起来文本概括对文字工作者有帮助,但事实上文本概括的应用可不止于此,例如一个常见的场景是电商商家对客户海量、冗长的评论进行概括,服务商能够高效地浏览更多评论,洞悉客户的偏好,从而指导平台与商家提供更优质的服务。
以下是对从某电商平台上随意找到的一段相对较长的客户评论进行文本概括的示例,示例中隐去了具体平台的名称,修改了一个错别字,其余均保持评价原文不动。
请将以下客户对商品的评价进行概括,不要超过30字。
XX上的东西质量有保证,我拿少2百左右,价格便宜,方法闺蜜说的,买着放心用着舒心。客服也很好,消费者权益有保障,在XX上买的东西耐心,质量经的起考验。大瓶用的持久,小样出门旅行带着很方便,总之就是好。韩国后天气丹已收到,物流快,客服服务好,有诚信,收到货,包装完好回头客。快递送货及时,五星好评。产品包装:包装高端大气上档次。吸收效果:吸收效果很好!保湿程度:夜间使用非常保湿。适合肤质:敏感肌也可以用,味道很好闻。我真的非常喜欢它,非常支持它,质量非常好,和卖家描述的一模一样,我非常满意。我真的很喜欢它,它完全超出了预期的价值,交货速度非常快,包装非常仔细和紧凑,物流公司有一个良好的服务态度,交货速度非常快,我非常满意购物,包装很好,水和乳液两大瓶,可以用很久,和以前买的小样香味儿一样的,用后不油腻,我是敏感肌,用后暂时没有出现问题,再使用一阵再追评!


网上购物涉及的服务商包括平台、卖家、物流等,不同的服务商对客户评价的关注点也不尽相同,在对文本进行概括时,也可以指出你所关注的重点信息,此时模型将在概括时有所侧重。
例如物流服务商可使用以下提示进行概括:
请重新对上述评价进行概括,不要超过30字,并且聚焦在物流上。

上述示例使用了迭代的方式进行提示,这是基于缩减本文篇幅的考虑。在真实场景中,作为物流服务商应该是在第一轮提示中就明确自己的要求。 基于相同原因,以下示例也类似使用了迭代提示。
对于产品卖家或生产厂家,其关注点应当是产品价格与质量。
请重新对上述评价进行概括,不要超过30字,并且聚焦在产品价格与质量上。

对其他用户,产品使用效果可能是最先关注的。
请重新对上述评价进行概括,不要超过30字,并且聚焦在产品使用效果上。

如果结合大语言模型提供的API通过编程实现上述交互,显然可以面向不同角色迅速提供客户对商品及相关服务的反馈,相比人工一条一条去阅读蜗牛般的速度,可谓一日千里!


找到位置,签名的话见:
https://www.cnblogs.com/vipsoft/p/18644127

新项目可以尝试一下 iText 7 , 我这边是老项目所以还是继续使用 iText 5,主打够用
iText 5 没有直接提供获取文本精确位置的功能。它只能提取文本内容,而文本位置通常需要通过额外的解析和计算来确定。

思路:在同一行,且一些词是连续的,前后没有空白字符串,即认为是一个词
需要特殊处理:

  • “姓 名:” 中间有空格
  • 读取PDF时,有些肉眼看上去是一行的字,可能会被解析为多个,导致找不到满足条件的关键字

image

添加引用

<itextpdf.version>5.5.13</itextpdf.version>
<itext-asian.version>5.2.0</itext-asian.version>

<dependency>
    <groupId>com.itextpdf</groupId>
    <artifactId>itextpdf</artifactId>
    <version>${itextpdf.version}</version>
</dependency>
<!--没有这个的话,添加文字会报错-->
<dependency>
    <groupId>com.itextpdf</groupId>
    <artifactId>itext-asian</artifactId>
    <version>${itext-asian.version}</version>
</dependency>

添加工具类

package com.vipsoft.web;

import cn.hutool.core.util.StrUtil;
import com.itextpdf.text.pdf.parser.ImageRenderInfo;
import com.itextpdf.text.pdf.parser.RenderListener;
import com.itextpdf.text.pdf.parser.TextRenderInfo;
import com.itextpdf.awt.geom.Rectangle2D.Float;

import java.util.ArrayList;
import java.util.List;


public class KeyWordPositionListener implements RenderListener {

    /**
     * 用来存储页面上所有的词
     * - 排除连续空格
     */
    private List<WordItem> allItems = new ArrayList<WordItem>();

    /**
     * 搜索关键词
     */
    private String keyWord;
    /**
     * 是否是新的词
     */
    private boolean newWord = false;
    /**
     * 记录上一个字符 -- 用于判断是否是一组词
     */
    private WordItem prevItem = new WordItem();

    /**
     * 已找到的词信息
     */
    private WordItem wordItem;

    public WordItem getWordItem() {
        return wordItem;
    }

    public void setKeyWord(String keyWord) {
        this.keyWord = keyWord;
    }

    @Override
    public void beginTextBlock() {
        // TODO Auto-generated method stub
    }

    /**
     * 方法会在解析文本时被调用,它检查每个文本片段是否包含关键词,并记录其位置。
     *
     * @param renderInfo
     */
    @Override
    public void renderText(TextRenderInfo renderInfo) {
        if (wordItem != null || StrUtil.isEmpty(keyWord)) {
            return;
        }
        // 读取PDF时,有些肉眼看上去是一行的字,可能会被解析为多个,导致找不到满足条件的关键字,这里做了简单的处理
        // 即如果一些词是连续的,前后没有空白字符串,即认为是一个词
        String content = renderInfo.getText().trim();
        Float textRectangle = renderInfo.getBaseline().getBoundingRectange();
        if (StrUtil.isEmpty(content)) {
            // 当前扫出来的是空字符串,视新一个新的词即将开始
            newWord = true;
//            System.out.println("扫出空的,跳过  x=" + textRectangle.getX() + " y=" + textRectangle.getY());
            return;
        }
        if (StrUtil.isEmpty(prevItem.getContent())) {
            // 这段可以不需要
            // prevItem 中还没有存内容的,当前文字也视为新的词
            newWord = true;
//            System.out.println("prevItem 中还没有存内容的,视为新词");
        }
        if (StrUtil.isNotEmpty(prevItem.getContent()) && (Math.abs((int) prevItem.getY() - (int) textRectangle.getY()) > 5)) {
            //Y 正负2内,视为同一行
            System.out.println("不在同一行:prevItem=" + prevItem.getContent() + " x=" + (int) prevItem.getX() + " y=" + (int) prevItem.getY());
            System.out.println("不在同一行:content=" + content + " x=" + (int) textRectangle.getX() + " y=" + (int) textRectangle.getY());
            System.out.println("当前内容和prevItem 不在同一行,视为新词");
            newWord = true;
        }
        if (newWord) {
            //重置
            System.out.println("重置 prevItem: " + prevItem.getContent());
            prevItem = new WordItem();
        }
        System.out.println("已扫到字:content=" + content + " x=" + textRectangle.getX() + " y=" + textRectangle.getY());
        String preContent = StrUtil.isNotEmpty(prevItem.getContent()) ? prevItem.getContent() : "";
        prevItem.setContent(preContent + content);
        prevItem.setX(textRectangle.getX());
        prevItem.setY(textRectangle.getY());
        if (prevItem.getContent().contains(keyWord)) {
            //System.out.println("找到了【" + keyWord + "】 " + prevItem.getContent() + " x= " + prevItem.getX() + " y= " + prevItem.getY());
            wordItem = prevItem;
        }
        newWord = false;
    }

    @Override
    public void endTextBlock() {
        // TODO Auto-generated method stub
    }

    @Override
    public void renderImage(ImageRenderInfo renderInfo) {
        // TODO Auto-generated method stub
    }

}

/**
 * 存储一个词的信息
 */
class WordItem {
    private String content;
    private double x;
    private double y;

   ... getters and setters ...
}

调用

@Test
void searchText() throws Exception {
    String filepath = "D:\\Report.pdf";
    String keyWord = "审核医生";
    int page = 1;
    PdfReader pdfReader = new PdfReader(filepath);
    //int pageNum = pdfReader.getNumberOfPages(); //循环没页PDF查找
    PdfReaderContentParser pdfReaderContentParser = new PdfReaderContentParser(pdfReader);
    KeyWordPositionListener renderListener = new KeyWordPositionListener();
    renderListener.setKeyWord(keyWord);
    pdfReaderContentParser.processContent(page, renderListener);
    WordItem wordItem = renderListener.getWordItem();
    if (wordItem == null) {
        System.out.println("没找到 " + keyWord);
        return;
    }
    System.out.println("找到了【" + keyWord + "】 " + wordItem.getContent() + " x= " + wordItem.getX() + " y= " + wordItem.getY());
}

前几篇我们介绍了如何使用 SK + ollama 跟 LLM 进行基本的对话。如果只是对话的话其实不用什么 SK 也是可以的。今天让我们给 LLM 整点活,让它真的给我们干点啥。

What is Plugin?

Plugins are a key component of Semantic Kernel. If you have already used plugins from ChatGPT or Copilot extensions in Microsoft 365, you’re already familiar with them. With plugins, you can encapsulate your existing APIs into a collection that can be used by an AI. This allows you to give your AI the ability to perform actions that it wouldn’t be able to do otherwise.
Behind the scenes, Semantic Kernel leverages function calling, a native feature of most of the latest LLMs to allow LLMs, to perform planning and to invoke your APIs. With function calling, LLMs can request (i.e., call) a particular function. Semantic Kernel then marshals the request to the appropriate function in your codebase and returns the results back to the LLM so the LLM can generate a final response.

以上是微软文档的原话。说人话:
Plugins 是 SK 的关键组件。基于 Plugins 你可以封装已有的 API 给 AI 使用。这给了 AI 执行动作的能力。在背后,SK 利用了大多数最新大语言模型 (LLMs) 的本地功能调用功能,使LLMs能够进行规划并调用您的API。通过功能调用,LLMs可以请求(即调用)特定的函数。然后,Semantic Kernel 将请求传递给代码库中的相应函数,并将结果返回给LLM,以便LLM生成最终的响应。
说的更直白一点,Plugins 给 LLM 提供了方法调用的能力。这就比较有意思了。我们知道 LLM 是基于过往的内容训练出来的,也就是说 LLM 并不能回答当前的一些信息,因为它不知道。比如你问它今天有什么新闻,它肯定不知道,因为这不在它的训练集里面。或者你问它今天天气怎么样它也不知道。同样它也没有办法给你做一些特定领域的事情,比如你让他们给某某发一条短信,它做不到,因为它没有这个能力。但是现在有了 Plugin,这一切就有了可能。

以下让我们使用 SK + Plugin 给 LLM 添加感知天气的能力。当我们问 LLM 某个城市的天气的时候,它能精确的给出回答。
在我们开始之前还是先试试直接问 LLM 天气问题会得到什么结果:

可以看到当我问 What is the weather now of SuZhou ? 后 LLM 直接说它不能获得实时数据。

定义 WeatherPlugin

using System.ComponentModel;
using Microsoft.SemanticKernel;

namespace SKLearning
{
    public sealed class WeatherPlugin
    {
        [KernelFunction, Description("Gets the weather details of a given location")]
        [return: Description("Weather details")]        
        public static async Task<string> GetWeatherByLocation([Description("name of the location")]string location)
        {
            var key = "...";
            var url = @$"http://api.weatherapi.com/v1/current.json?key={key}&q={location}";
            
            using var client = new HttpClient();
            var response = await client.GetAsync(url);
            var content = await response.Content.ReadAsStringAsync();
            
            Console.WriteLine(content);

            return content;
        }
    }
}

一个 plugin 就是一个 C# 的类。在这个类里面承载了一个或N个方法。我们给方法添加描述,给入参,出参添加描述好让 LLM 认识这个方法的作用。这个描述非常重要,请使用尽量简洁明了的语言。
我们可以看到 WeatherPlugin 里的 GetWeatherByLocation 是通过要给 API 实时获取某个城市的天气信息。这个返回值是 JSON 格式的,并不需要进行特殊的处理,LLM 可以直接识别里面的内容。

添加 Plugin 到 kernel

var httpClient = new HttpClient();
var builder = Kernel.CreateBuilder()
    .AddOpenAIChatCompletion(modelId: modelId!, apiKey: null, endpoint: endpoint, httpClient: httpClient);
var kernel = builder.Build();
kernel.Plugins.AddFromType<WeatherPlugin>();

以上代码片段演示了如何添加 OpenAI 的 Chat 服务以及如何添加 Plugin 。

与 AI 对话

var settings = new OpenAIPromptExecutionSettings()
    { Temperature = 0.0, ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions };
var chatCompletionService = kernel.GetRequiredService<IChatCompletionService>();
var history = new ChatHistory();
var initMessage =
    "I am a weatherman. I can tell you the weather of any location. Try asking me about the weather of a location.";
history.AddSystemMessage(initMessage);
Console.WriteLine(initMessage);

while (true)
{
    Console.BackgroundColor = ConsoleColor.Black;
    Console.Write("You:");

    var input = Console.ReadLine();

    if (string.IsNullOrWhiteSpace(input))
    {
        break;
    }

    history.AddUserMessage(input);
    // Get the response from the AI
    var contents = await chatCompletionService.GetChatMessageContentsAsync(history, settings, kernel);

    foreach (var chatMessageContent in contents)
    {
        var content = chatMessageContent.Content;
        Console.BackgroundColor = ConsoleColor.DarkGreen;
        Console.WriteLine($"AI: {content}");
        history.AddMessage(chatMessageContent.Role, content ?? "");
    }
}

以上内容跟上次演示的内容没啥特别大的区别,无非就是读取用户的输入,等待 LLM 的回答。

试一下

让我们运行程序,然后再次问 What is the weather now of SuZhou?
可以看到 LLM 精确的回答出了当前苏州的天气:

I am a weatherman. I can tell you the weather of any location. Try asking me about the weather of a location.

You: What is the weather now of SuZhou?

AI: The current weather in Suzhou, China is patchy light drizzle with a temperature of 17.2°C (62.9°F). The wind is blowing at 3.6 mph (5.8 kph) from the NNE direction. The humidity is 86% and the visibility is 5 km (3 miles).

总结

通过以上演示,我们自定义了一个实时获取天气信息的 plugin 给 LLM 使用。当我们问到天气信息的时候 LLM 会实时调用这个方法,然后使用方法结果构造一个可读性非常高的回答。有了 plugin 之后 LLM 真正的可以触发一些动作,执行一些任务,获取实时信息了。

希望此文对你有所帮助,谢谢。